@nextclaw/ui 0.12.35-beta.2 → 0.12.35-beta.4

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 (118) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/dist/assets/api-BTZui7nj.js +15 -0
  3. package/dist/assets/{book-open--T9dJHnp.js → book-open-qc0UVn6m.js} +1 -1
  4. package/dist/assets/channels-list-page-DBtU_M1Y.js +8 -0
  5. package/dist/assets/{chat-page-Doi-sasO.js → chat-page-BcoJYo91.js} +2 -2
  6. package/dist/assets/config-split-page-DW8tP9Bc.js +1 -0
  7. package/dist/assets/createLucideIcon-CA0sgoMJ.js +1 -0
  8. package/dist/assets/desktop-update-config-DARSwQG8.js +1 -0
  9. package/dist/assets/dist-D5zsy7pX.js +1 -0
  10. package/dist/assets/dist-DNxIa3pe.js +41 -0
  11. package/dist/assets/doc-browser-B9skxzVo.js +1 -0
  12. package/dist/assets/doc-browser-CwyITRXx.js +1 -0
  13. package/dist/assets/doc-browser-context-Do2qSrB8.js +1 -0
  14. package/dist/assets/{external-link-CZ9bQY0L.js → external-link-C9fbyi6k.js} +1 -1
  15. package/dist/assets/index-BYRP_BMj.css +1 -0
  16. package/dist/assets/index-DckPGTdM.js +103 -0
  17. package/dist/assets/mcp-marketplace-page-BiExW5wq.js +40 -0
  18. package/dist/assets/mcp-marketplace-page-Cj6MDSkE.js +1 -0
  19. package/dist/assets/model-config-DbdY7fF1.js +1 -0
  20. package/dist/assets/notice-card-CyiVcu_Z.js +5 -0
  21. package/dist/assets/play-D-s0C4MN.js +1 -0
  22. package/dist/assets/plus-DOwjM_kF.js +1 -0
  23. package/dist/assets/provider-scoped-model-input-Bk8jlpRm.js +1 -0
  24. package/dist/assets/providers-list-CJjNStDV.js +1 -0
  25. package/dist/assets/react-BaRotFd6.js +1 -0
  26. package/dist/assets/remote-gl8QckNc.js +1 -0
  27. package/dist/assets/runtime-config-page-BMFc1yzf.js +1 -0
  28. package/dist/assets/{save-B6wbBLkU.js → save-CG4Hb1u8.js} +1 -1
  29. package/dist/assets/{search-config-BtCNY431.js → search-config-DUOR26if.js} +1 -1
  30. package/dist/assets/{search-Czito3Dk.js → search-lWpOIHEn.js} +1 -1
  31. package/dist/assets/secrets-config-ZbLzjIRx.js +3 -0
  32. package/dist/assets/setting-row-CNfTKDjc.js +1 -0
  33. package/dist/assets/tabs-custom-BbZoysAl.js +1 -0
  34. package/dist/assets/tag-chip-Dl8JbhC7.js +1 -0
  35. package/dist/assets/x-D6GwT_8k.js +1 -0
  36. package/dist/index.html +15 -17
  37. package/package.json +8 -9
  38. package/src/app/components/app-presenter-provider.tsx +21 -0
  39. package/src/app/components/layout/sidebar.layout.test.tsx +2 -2
  40. package/src/app/components/layout/sidebar.tsx +3 -3
  41. package/src/app/index.tsx +5 -3
  42. package/src/app/presenters/app.presenter.ts +24 -0
  43. package/src/features/account/components/account-panel.tsx +11 -11
  44. package/src/features/account/index.ts +1 -4
  45. package/src/features/account/managers/account.manager.ts +52 -27
  46. package/src/features/account/stores/account.store.ts +0 -15
  47. package/src/features/apps/components/apps-panel.tsx +43 -0
  48. package/src/features/apps/index.ts +1 -0
  49. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +8 -8
  50. package/src/features/chat/components/layout/chat-sidebar.tsx +3 -2
  51. package/src/features/chat/managers/chat-session-list.manager.test.ts +58 -0
  52. package/src/features/chat/stores/chat-session-list.store.ts +58 -7
  53. package/src/features/panel-apps/components/panel-app-toolbar.tsx +4 -4
  54. package/src/features/panel-apps/components/panel-apps-list.tsx +6 -2
  55. package/src/features/panel-apps/index.ts +3 -1
  56. package/src/features/panel-apps/managers/panel-app-bridge.manager.test.ts +111 -0
  57. package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +247 -0
  58. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.test.ts +23 -0
  59. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +42 -16
  60. package/src/features/remote/components/remote-access-page.test.tsx +2 -2
  61. package/src/features/remote/components/remote-access-page.tsx +9 -9
  62. package/src/features/remote/managers/remote-access.manager.ts +24 -41
  63. package/src/features/service-apps/components/service-action-authorization-dialog.tsx +78 -0
  64. package/src/features/service-apps/components/service-apps-panel.tsx +290 -0
  65. package/src/features/service-apps/hooks/use-service-apps.ts +66 -0
  66. package/src/features/service-apps/index.ts +3 -0
  67. package/src/features/service-apps/managers/service-action-authorization.manager.ts +10 -0
  68. package/src/features/service-apps/stores/service-action-authorization.store.ts +53 -0
  69. package/src/shared/components/doc-browser/doc-browser-context.test.tsx +150 -1
  70. package/src/shared/components/doc-browser/doc-browser-context.tsx +34 -443
  71. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +2 -2
  72. package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +7 -0
  73. package/src/shared/components/doc-browser/doc-browser.test.tsx +82 -2
  74. package/src/shared/components/doc-browser/doc-browser.tsx +147 -135
  75. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +272 -0
  76. package/src/shared/components/doc-browser/stores/doc-browser.store.ts +140 -0
  77. package/src/shared/components/doc-browser/types/doc-browser.types.ts +47 -0
  78. package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +47 -0
  79. package/src/shared/components/doc-browser/utils/doc-browser-url.utils.ts +95 -0
  80. package/src/shared/lib/api/managers/client.manager.ts +2 -1
  81. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +31 -0
  82. package/src/shared/lib/transport/local-transport.service.ts +18 -2
  83. package/src/shared/lib/transport/remote-transport.service.ts +19 -3
  84. package/src/shared/lib/transport/request-raw-api-response.utils.ts +1 -1
  85. package/dist/assets/api-D3uoaOR8.js +0 -15
  86. package/dist/assets/channels-list-page-CzDMCXR_.js +0 -8
  87. package/dist/assets/config-split-page-Dutq3R5o.js +0 -1
  88. package/dist/assets/createLucideIcon-Cwr2o6Zq.js +0 -1
  89. package/dist/assets/desktop-update-config-Dd4DdPH3.js +0 -1
  90. package/dist/assets/dist-e5WATjIW.js +0 -1
  91. package/dist/assets/doc-browser-DInzGY0W.js +0 -1
  92. package/dist/assets/doc-browser-DxVvmYQk.js +0 -1
  93. package/dist/assets/doc-browser-context-CLczyX2Z.js +0 -1
  94. package/dist/assets/es2015-BJw-hrex.js +0 -41
  95. package/dist/assets/i18n-tWKGEGxB.js +0 -1
  96. package/dist/assets/index-HvJDzfSU.css +0 -1
  97. package/dist/assets/index-dHwM4pEn.js +0 -103
  98. package/dist/assets/key-round-B8tTb3ZJ.js +0 -1
  99. package/dist/assets/mcp-marketplace-page-D-dydvbL.js +0 -1
  100. package/dist/assets/mcp-marketplace-page-n9Z25Cyk.js +0 -40
  101. package/dist/assets/model-config-8BVoSVXQ.js +0 -1
  102. package/dist/assets/notice-card-DUDbOvqx.js +0 -5
  103. package/dist/assets/play-CV_kwi9X.js +0 -1
  104. package/dist/assets/plus-BqfNq7Tu.js +0 -1
  105. package/dist/assets/provider-scoped-model-input-eO4XY0iY.js +0 -1
  106. package/dist/assets/providers-list-CjJmW-od.js +0 -1
  107. package/dist/assets/remote-DxTFnQm8.js +0 -1
  108. package/dist/assets/rotate-cw-BKEhWuWp.js +0 -1
  109. package/dist/assets/runtime-config-page-Ba6atCMq.js +0 -1
  110. package/dist/assets/secrets-config-Ddh05-J5.js +0 -3
  111. package/dist/assets/select-CmmDgG8o.js +0 -1
  112. package/dist/assets/setting-row-BUO6FeN1.js +0 -1
  113. package/dist/assets/tabs-custom-D3IZL9WX.js +0 -1
  114. package/dist/assets/tag-chip-Dm_pOtE3.js +0 -1
  115. package/dist/assets/x-DBP3WiC9.js +0 -1
  116. package/src/app/components/app-manager-provider.tsx +0 -20
  117. package/src/app/managers/app.manager.ts +0 -12
  118. /package/dist/assets/{config-hints-DGfwbumO.js → config-hints-CTqBnCDp.js} +0 -0
@@ -0,0 +1,43 @@
1
+ import { AppWindow, Server } from 'lucide-react';
2
+ import type { PanelAppEntryView } from '@/shared/lib/api';
3
+ import { PanelAppsList } from '@/features/panel-apps';
4
+ import { ServiceAppsPanel } from '@/features/service-apps';
5
+ import { Tabs, TabsList, TabsTrigger } from '@/shared/components/ui/tabs';
6
+ import { t } from '@/shared/lib/i18n';
7
+
8
+ export type AppsPanelTab = 'panel-apps' | 'service-apps';
9
+
10
+ export function AppsPanel({
11
+ activeTab,
12
+ onActiveTabChange,
13
+ onOpenPanelApp,
14
+ }: {
15
+ activeTab: AppsPanelTab;
16
+ onActiveTabChange: (tab: AppsPanelTab) => void;
17
+ onOpenPanelApp: (entry: PanelAppEntryView) => void;
18
+ }) {
19
+ const headerTabs = (
20
+ <Tabs value={activeTab} onValueChange={(value) => onActiveTabChange(value as AppsPanelTab)}>
21
+ <TabsList className="grid h-auto w-[min(340px,100%)] grid-cols-2 rounded-lg bg-gray-100/70 p-0.5">
22
+ <TabsTrigger value="panel-apps" className="gap-1.5 px-2 py-1.5 text-xs">
23
+ <AppWindow className="h-3.5 w-3.5" />
24
+ {t('panelAppsTitle')}
25
+ </TabsTrigger>
26
+ <TabsTrigger value="service-apps" className="gap-1.5 px-2 py-1.5 text-xs">
27
+ <Server className="h-3.5 w-3.5" />
28
+ {t('serviceAppsTitle')}
29
+ </TabsTrigger>
30
+ </TabsList>
31
+ </Tabs>
32
+ );
33
+
34
+ return (
35
+ <div className="h-full min-h-0 bg-white">
36
+ {activeTab === 'panel-apps' ? (
37
+ <PanelAppsList headerContent={headerTabs} onOpenPanelApp={onOpenPanelApp} />
38
+ ) : (
39
+ <ServiceAppsPanel headerContent={headerTabs} />
40
+ )}
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1 @@
1
+ export { AppsPanel, type AppsPanelTab } from './components/apps-panel';
@@ -6,7 +6,7 @@ import type { UiTheme } from '@/shared/lib/theme';
6
6
  import {
7
7
  BookOpen,
8
8
  ChevronRight,
9
- AppWindow,
9
+ Boxes,
10
10
  Languages,
11
11
  Palette,
12
12
  Settings,
@@ -30,7 +30,7 @@ type ChatSidebarUtilityMenuProps = {
30
30
  languageOptions: ChatSidebarUtilityOption<I18nLanguage>[];
31
31
  onSelectLanguage: (language: I18nLanguage) => void;
32
32
  onOpenDocs: () => void;
33
- onOpenPanelApps: () => void;
33
+ onOpenApps: () => void;
34
34
  };
35
35
 
36
36
  export function ChatSidebarUtilityMenu({
@@ -45,15 +45,15 @@ export function ChatSidebarUtilityMenu({
45
45
  languageOptions,
46
46
  onSelectLanguage,
47
47
  onOpenDocs,
48
- onOpenPanelApps,
48
+ onOpenApps,
49
49
  }: ChatSidebarUtilityMenuProps) {
50
50
  const handleOpenDocs = () => {
51
51
  onOpenDocs();
52
52
  onOpenChange(false);
53
53
  };
54
54
 
55
- const handleOpenPanelApps = () => {
56
- onOpenPanelApps();
55
+ const handleOpenApps = () => {
56
+ onOpenApps();
57
57
  onOpenChange(false);
58
58
  };
59
59
 
@@ -92,11 +92,11 @@ export function ChatSidebarUtilityMenu({
92
92
  </button>
93
93
  <button
94
94
  type="button"
95
- onClick={handleOpenPanelApps}
95
+ onClick={handleOpenApps}
96
96
  className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium text-gray-700 transition-colors hover:bg-gray-100"
97
97
  >
98
- <AppWindow className="h-4 w-4 text-gray-400" />
99
- <span className="flex-1 text-left">{t('panelAppsTitle')}</span>
98
+ <Boxes className="h-4 w-4 text-gray-400" />
99
+ <span className="flex-1 text-left">{t('appsTitle')}</span>
100
100
  </button>
101
101
  </div>
102
102
 
@@ -30,7 +30,7 @@ import {
30
30
  ChatSidebarMobileToolbar,
31
31
  } from '@/features/chat/components/layout/chat-sidebar-toolbar';
32
32
  import { ChatSidebarUtilityMenu } from '@/features/chat/components/layout/chat-sidebar-utility-menu';
33
- import { openPanelApps } from '@/features/panel-apps';
33
+ import { openApps } from '@/features/panel-apps';
34
34
  import { isWindowsDesktopHost } from '@/platforms/desktop';
35
35
 
36
36
  type DateGroup = {
@@ -379,7 +379,8 @@ export function ChatSidebar({
379
379
  currentLanguageLabel={currentLanguageLabel}
380
380
  languageOptions={utilityLanguageOptions}
381
381
  onSelectLanguage={handleLanguageSwitch}
382
- onOpenDocs={() => docBrowser.open(undefined, { kind: 'docs', newTab: true, title: 'Docs' })} onOpenPanelApps={() => openPanelApps(docBrowser)}
382
+ onOpenDocs={() => docBrowser.open(undefined, { kind: 'docs', newTab: true, title: 'Docs' })}
383
+ onOpenApps={() => openApps(docBrowser)}
383
384
  />
384
385
  </div>
385
386
  ) : null}
@@ -5,6 +5,16 @@ import { useChatInputStore } from '@/features/chat/stores/chat-input.store';
5
5
  import { useChatSessionListStore } from '@/features/chat/stores/chat-session-list.store';
6
6
  import { useChatThreadStore } from '@/features/chat/stores/chat-thread.store';
7
7
 
8
+ const chatSessionListModeStorageKey = 'nextclaw.chat.session-list.mode';
9
+
10
+ function createLocalStoragePersistStorage() {
11
+ return {
12
+ getItem: (name: string) => JSON.parse(window.localStorage.getItem(name) ?? 'null'),
13
+ setItem: (name: string, value: unknown) => window.localStorage.setItem(name, JSON.stringify(value)),
14
+ removeItem: (name: string) => window.localStorage.removeItem(name)
15
+ };
16
+ }
17
+
8
18
  const mocks = vi.hoisted(() => ({
9
19
  updateNcpSession: vi.fn(),
10
20
  }));
@@ -19,6 +29,8 @@ vi.mock('@/shared/lib/api', async (importOriginal) => {
19
29
 
20
30
  describe('ChatSessionListManager', () => {
21
31
  beforeEach(() => {
32
+ window.localStorage.clear();
33
+ useChatSessionListStore.persist.setOptions({ storage: createLocalStoragePersistStorage() });
22
34
  mocks.updateNcpSession.mockReset();
23
35
  mocks.updateNcpSession.mockResolvedValue({});
24
36
  useChatInputStore.setState({
@@ -193,6 +205,13 @@ describe('ChatSessionListManager', () => {
193
205
 
194
206
  expect(useChatSessionListStore.getState().snapshot.listMode).toBe('project-first');
195
207
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
208
+ expect(JSON.parse(window.localStorage.getItem(chatSessionListModeStorageKey) ?? '{}')).toMatchObject({
209
+ state: {
210
+ snapshot: {
211
+ listMode: 'project-first'
212
+ }
213
+ }
214
+ });
196
215
  });
197
216
 
198
217
  it('marks a session as read through the session list owner boundary', () => {
@@ -252,3 +271,42 @@ describe('ChatSessionListManager', () => {
252
271
  expect(uiManager.goToSession).toHaveBeenCalledWith('ncp-materialized-session', { replace: true });
253
272
  });
254
273
  });
274
+
275
+ describe('ChatSessionListStore persistence', () => {
276
+ beforeEach(() => {
277
+ window.localStorage.clear();
278
+ useChatSessionListStore.persist.setOptions({ storage: createLocalStoragePersistStorage() });
279
+ useChatSessionListStore.setState({
280
+ snapshot: {
281
+ ...useChatSessionListStore.getState().snapshot,
282
+ listMode: 'time-first'
283
+ }
284
+ });
285
+ });
286
+
287
+ it('falls back to time-first when the persisted sidebar list mode is invalid', () => {
288
+ useChatSessionListStore.persist.setOptions({
289
+ storage: {
290
+ getItem: () => ({ state: { snapshot: { listMode: 'sideways' } } }),
291
+ setItem: vi.fn(),
292
+ removeItem: vi.fn()
293
+ }
294
+ });
295
+
296
+ useChatSessionListStore.persist.rehydrate();
297
+
298
+ expect(useChatSessionListStore.getState().snapshot.listMode).toBe('time-first');
299
+ });
300
+
301
+ it('hydrates the sidebar list mode from persisted preference on store initialization', async () => {
302
+ window.localStorage.setItem(
303
+ chatSessionListModeStorageKey,
304
+ JSON.stringify({ state: { snapshot: { listMode: 'project-first' } }, version: 0 })
305
+ );
306
+ vi.resetModules();
307
+
308
+ const hydratedStoreModule = await import('@/features/chat/stores/chat-session-list.store');
309
+
310
+ expect(hydratedStoreModule.useChatSessionListStore.getState().snapshot.listMode).toBe('project-first');
311
+ });
312
+ });
@@ -1,4 +1,5 @@
1
1
  import { create, type StateCreator } from 'zustand';
2
+ import { createJSONStorage, persist } from 'zustand/middleware';
2
3
  import type { SessionRunStatus } from '@/features/chat/types/session-run-status.types';
3
4
  export type ChatSessionListMode = 'time-first' | 'project-first';
4
5
  export type ChatSessionListSnapshot = {
@@ -8,6 +9,30 @@ export type ChatSessionListSnapshot = {
8
9
  listMode: ChatSessionListMode;
9
10
  };
10
11
 
12
+ const CHAT_SESSION_LIST_MODE_STORAGE_KEY = 'nextclaw.chat.session-list.mode';
13
+ const CHAT_SESSION_LIST_DEFAULT_MODE: ChatSessionListMode = 'time-first';
14
+
15
+ type PersistedChatSessionListStore = {
16
+ snapshot?: {
17
+ listMode?: unknown;
18
+ };
19
+ };
20
+
21
+ function isChatSessionListMode(value: unknown): value is ChatSessionListMode {
22
+ return value === 'time-first' || value === 'project-first';
23
+ }
24
+
25
+ function resolvePersistedChatSessionListMode(persistedState: unknown): ChatSessionListMode | null {
26
+ if (!persistedState || typeof persistedState !== 'object') {
27
+ return null;
28
+ }
29
+ const { snapshot } = persistedState as PersistedChatSessionListStore;
30
+ if (!snapshot || typeof snapshot !== 'object') {
31
+ return null;
32
+ }
33
+ return isChatSessionListMode(snapshot.listMode) ? snapshot.listMode : null;
34
+ }
35
+
11
36
  export function hasUnreadSessionUpdate(
12
37
  lastMessageAt: string | null | undefined,
13
38
  readAt: string | undefined,
@@ -50,7 +75,7 @@ const initialSnapshot: ChatSessionListSnapshot = {
50
75
  selectedSessionKey: null,
51
76
  selectedAgentId: 'main',
52
77
  query: '',
53
- listMode: 'time-first'
78
+ listMode: CHAT_SESSION_LIST_DEFAULT_MODE
54
79
  };
55
80
 
56
81
  function createSetSnapshotAction(set: ChatSessionListStoreSet) {
@@ -85,9 +110,35 @@ function createMarkSessionReadAction(set: ChatSessionListStoreSet) {
85
110
  });
86
111
  }
87
112
 
88
- export const useChatSessionListStore = create<ChatSessionListStore>((set) => ({
89
- snapshot: initialSnapshot,
90
- optimisticReadAtBySessionKey: {},
91
- setSnapshot: createSetSnapshotAction(set),
92
- markSessionRead: createMarkSessionReadAction(set)
93
- }));
113
+ export const useChatSessionListStore = create<ChatSessionListStore>()(
114
+ persist(
115
+ (set) => ({
116
+ snapshot: initialSnapshot,
117
+ optimisticReadAtBySessionKey: {},
118
+ setSnapshot: createSetSnapshotAction(set),
119
+ markSessionRead: createMarkSessionReadAction(set)
120
+ }),
121
+ {
122
+ name: CHAT_SESSION_LIST_MODE_STORAGE_KEY,
123
+ storage: createJSONStorage(() => window.localStorage),
124
+ partialize: (state): PersistedChatSessionListStore => ({
125
+ snapshot: {
126
+ listMode: state.snapshot.listMode
127
+ }
128
+ }),
129
+ merge: (persistedState, currentState) => {
130
+ const listMode = resolvePersistedChatSessionListMode(persistedState);
131
+ if (!listMode) {
132
+ return currentState;
133
+ }
134
+ return {
135
+ ...currentState,
136
+ snapshot: {
137
+ ...currentState.snapshot,
138
+ listMode
139
+ }
140
+ };
141
+ }
142
+ }
143
+ )
144
+ );
@@ -2,23 +2,23 @@ import { ArrowLeft, RefreshCw } from 'lucide-react';
2
2
  import { t } from '@/shared/lib/i18n';
3
3
 
4
4
  type PanelAppToolbarProps = {
5
- onOpenPanelApps: () => void;
5
+ onOpenApps: () => void;
6
6
  onRefresh: () => void;
7
7
  };
8
8
 
9
9
  export function PanelAppToolbar({
10
- onOpenPanelApps,
10
+ onOpenApps,
11
11
  onRefresh,
12
12
  }: PanelAppToolbarProps) {
13
13
  return (
14
14
  <div className="flex items-center justify-between gap-2 px-3.5 py-2 bg-white border-b border-gray-100 shrink-0">
15
15
  <button
16
16
  type="button"
17
- onClick={onOpenPanelApps}
17
+ onClick={onOpenApps}
18
18
  className="inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
19
19
  >
20
20
  <ArrowLeft className="h-3.5 w-3.5" />
21
- {t('panelAppsTitle')}
21
+ {t('appsTitle')}
22
22
  </button>
23
23
  <button
24
24
  type="button"
@@ -1,4 +1,4 @@
1
- import { useMemo, useState } from 'react';
1
+ import { useMemo, useState, type ReactNode } from 'react';
2
2
  import { HelpCircle, RefreshCw } from 'lucide-react';
3
3
  import { PanelAppListItem } from '@/features/panel-apps/components/panel-app-list-item';
4
4
  import { usePanelApps, useRecordPanelAppOpened, useUpdatePanelAppPreferences } from '@/features/panel-apps/hooks/use-panel-apps';
@@ -10,8 +10,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/shar
10
10
  import { t } from '@/shared/lib/i18n';
11
11
 
12
12
  export function PanelAppsList({
13
+ headerContent,
13
14
  onOpenPanelApp,
14
15
  }: {
16
+ headerContent?: ReactNode;
15
17
  onOpenPanelApp: (entry: PanelAppEntryView) => void;
16
18
  }) {
17
19
  const panelApps = usePanelApps();
@@ -54,7 +56,9 @@ export function PanelAppsList({
54
56
  <div className="flex h-full min-h-0 flex-col bg-white">
55
57
  <div className="flex items-center justify-between gap-2 border-b border-gray-100 px-4 py-3">
56
58
  <div className="flex min-w-0 items-center gap-1.5">
57
- <div className="truncate text-sm font-semibold text-gray-900">{t('panelAppsTitle')}</div>
59
+ {headerContent ?? (
60
+ <div className="truncate text-sm font-semibold text-gray-900">{t('panelAppsTitle')}</div>
61
+ )}
58
62
  {panelApps.data?.panelsPath ? (
59
63
  <TooltipProvider delayDuration={250}>
60
64
  <Tooltip>
@@ -1,4 +1,6 @@
1
+ export { PanelAppsList } from './components/panel-apps-list';
2
+ export { PanelAppBridgeManager } from './managers/panel-app-bridge.manager';
1
3
  export {
2
- openPanelApps,
4
+ openApps,
3
5
  PANEL_APPS_DOC_BROWSER_RENDERERS,
4
6
  } from './utils/panel-app-doc-browser.utils';
@@ -0,0 +1,111 @@
1
+ import { NextClawClientError } from '@nextclaw/client-sdk';
2
+ import { waitFor } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { PanelAppBridgeManager } from '@/features/panel-apps/managers/panel-app-bridge.manager';
5
+ import type { ServiceActionAuthorizationManager } from '@/features/service-apps';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ createBridgeSession: vi.fn(),
9
+ grantServiceAction: vi.fn(),
10
+ invokeServiceAction: vi.fn(),
11
+ listServiceActions: vi.fn(),
12
+ requestAuthorization: vi.fn(),
13
+ }));
14
+
15
+ vi.mock('@/shared/lib/api', () => ({
16
+ nextclawClient: {
17
+ panelApps: {
18
+ createBridgeSession: mocks.createBridgeSession,
19
+ },
20
+ serviceApps: {
21
+ grantServiceAction: mocks.grantServiceAction,
22
+ invokeServiceAction: mocks.invokeServiceAction,
23
+ listServiceActions: mocks.listServiceActions,
24
+ revokeServiceAction: vi.fn(),
25
+ },
26
+ },
27
+ }));
28
+
29
+ describe('PanelAppBridgeManager', () => {
30
+ it('opens the authorization flow before retrying a protected service action', async () => {
31
+ const manager = new PanelAppBridgeManager({
32
+ requestAuthorization: mocks.requestAuthorization,
33
+ } as unknown as ServiceActionAuthorizationManager);
34
+ const postMessage = vi.fn();
35
+ const contentWindow = { postMessage } as unknown as Window;
36
+ const iframe = { contentWindow } as HTMLIFrameElement;
37
+ const actionId = 'mood-tracker.saveMood';
38
+ mocks.createBridgeSession.mockResolvedValue({
39
+ expiresAt: '2026-05-28T00:00:00.000Z',
40
+ id: 'session-1',
41
+ panelAppId: 'mood-calendar',
42
+ tabId: 'tab-1',
43
+ token: 'token-1',
44
+ });
45
+ mocks.invokeServiceAction
46
+ .mockRejectedValueOnce(new NextClawClientError({
47
+ code: 'AUTHORIZATION_REQUIRED',
48
+ message: 'authorization required',
49
+ }))
50
+ .mockResolvedValueOnce({ actionId, result: { saved: true } });
51
+ mocks.listServiceActions.mockResolvedValue({
52
+ actions: [{
53
+ appId: 'mood-tracker',
54
+ description: 'Save daily mood entries',
55
+ id: actionId,
56
+ name: 'saveMood',
57
+ risk: 'write',
58
+ title: 'Save mood',
59
+ }],
60
+ });
61
+ mocks.requestAuthorization.mockResolvedValue(true);
62
+ mocks.grantServiceAction.mockResolvedValue({
63
+ actionId,
64
+ caller: { surface: 'panel-app', appId: 'mood-calendar' },
65
+ grantedAt: '2026-05-28T00:00:00.000Z',
66
+ risk: 'write',
67
+ });
68
+
69
+ manager.handleIframeMessage({
70
+ event: {
71
+ data: {
72
+ method: 'invoke',
73
+ payload: { actionId, input: { mood: 'happy' } },
74
+ requestId: 'request-1',
75
+ type: 'nextclaw:panel-app-service-actions:request',
76
+ },
77
+ source: contentWindow,
78
+ } as MessageEvent,
79
+ iframe,
80
+ tab: {
81
+ currentUrl: '/api/panel-apps/mood-calendar/content',
82
+ history: [],
83
+ historyIndex: 0,
84
+ id: 'tab-1',
85
+ kind: 'content',
86
+ navVersion: 0,
87
+ title: 'Mood Calendar',
88
+ },
89
+ });
90
+
91
+ await waitFor(() => expect(postMessage).toHaveBeenCalled());
92
+
93
+ expect(mocks.requestAuthorization).toHaveBeenCalledWith(expect.objectContaining({
94
+ actionId,
95
+ actionTitle: 'Save mood',
96
+ inputPreview: expect.stringContaining('happy'),
97
+ panelAppId: 'mood-calendar',
98
+ risk: 'write',
99
+ }));
100
+ expect(mocks.grantServiceAction).toHaveBeenCalledWith(actionId, {
101
+ bridgeSessionToken: 'token-1',
102
+ });
103
+ expect(mocks.invokeServiceAction).toHaveBeenCalledTimes(2);
104
+ expect(postMessage).toHaveBeenCalledWith({
105
+ data: { actionId, result: { saved: true } },
106
+ ok: true,
107
+ requestId: 'request-1',
108
+ type: 'nextclaw:panel-app-service-actions:response',
109
+ }, '*');
110
+ });
111
+ });