@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,247 @@
1
+ import { NextClawClientError } from '@nextclaw/client-sdk';
2
+ import type {
3
+ PanelAppBridgeSessionView,
4
+ ServiceActionGrantView,
5
+ ServiceActionInvokeResultView,
6
+ ServiceActionListView,
7
+ } from '@nextclaw/client-sdk';
8
+ import type { ServiceActionAuthorizationManager } from '@/features/service-apps';
9
+ import type { DocBrowserIframeMessageParams } from '@/shared/components/doc-browser/doc-browser-renderer.types';
10
+ import { nextclawClient } from '@/shared/lib/api';
11
+
12
+ type PanelAppBridgeRequest = {
13
+ type: 'nextclaw:panel-app-service-actions:request';
14
+ requestId: string;
15
+ method: 'invoke' | 'list' | 'requestGrant' | 'revokeGrant';
16
+ payload?: {
17
+ actionId?: string;
18
+ input?: Record<string, unknown>;
19
+ };
20
+ };
21
+
22
+ type PanelAppBridgeResponse =
23
+ | {
24
+ type: 'nextclaw:panel-app-service-actions:response';
25
+ requestId: string;
26
+ ok: true;
27
+ data: unknown;
28
+ }
29
+ | {
30
+ type: 'nextclaw:panel-app-service-actions:response';
31
+ requestId: string;
32
+ ok: false;
33
+ error: {
34
+ code?: string;
35
+ message: string;
36
+ details?: Record<string, unknown>;
37
+ };
38
+ };
39
+
40
+ export class PanelAppBridgeManager {
41
+ private readonly sessions = new Map<string, Promise<PanelAppBridgeSessionView>>();
42
+
43
+ constructor(private readonly authorizationManager: ServiceActionAuthorizationManager) {}
44
+
45
+ handleIframeMessage = ({ event, iframe, tab }: DocBrowserIframeMessageParams): void => {
46
+ if (!this.isBridgeRequest(event.data)) {
47
+ return;
48
+ }
49
+ if (!iframe?.contentWindow || event.source !== iframe.contentWindow) {
50
+ return;
51
+ }
52
+ void this.handleBridgeRequest({ event, iframe, tab }, event.data);
53
+ };
54
+
55
+ private handleBridgeRequest = async (
56
+ params: DocBrowserIframeMessageParams,
57
+ request: PanelAppBridgeRequest,
58
+ ): Promise<void> => {
59
+ try {
60
+ const session = await this.getSession(params);
61
+ const data = await this.dispatchRequest(session, request);
62
+ this.postResponse(params, {
63
+ type: 'nextclaw:panel-app-service-actions:response',
64
+ requestId: request.requestId,
65
+ ok: true,
66
+ data,
67
+ });
68
+ } catch (error) {
69
+ this.postResponse(params, {
70
+ type: 'nextclaw:panel-app-service-actions:response',
71
+ requestId: request.requestId,
72
+ ok: false,
73
+ error: this.toBridgeError(error),
74
+ });
75
+ }
76
+ };
77
+
78
+ private dispatchRequest = async (
79
+ session: PanelAppBridgeSessionView,
80
+ request: PanelAppBridgeRequest,
81
+ ): Promise<
82
+ | ServiceActionGrantView
83
+ | ServiceActionInvokeResultView
84
+ | ServiceActionListView
85
+ | { revoked: boolean }
86
+ > => {
87
+ switch (request.method) {
88
+ case 'list':
89
+ return await nextclawClient.serviceApps.listServiceActions({
90
+ bridgeSessionToken: session.token,
91
+ });
92
+ case 'requestGrant':
93
+ return await this.confirmAndGrant(session, this.requireActionId(request));
94
+ case 'revokeGrant':
95
+ return await nextclawClient.serviceApps.revokeServiceAction(
96
+ this.requireActionId(request),
97
+ { bridgeSessionToken: session.token },
98
+ );
99
+ case 'invoke':
100
+ return await this.invokeWithAuthorization(session, request);
101
+ default:
102
+ throw new Error(`Unsupported panel bridge method: ${String(request.method)}`);
103
+ }
104
+ };
105
+
106
+ private invokeWithAuthorization = async (
107
+ session: PanelAppBridgeSessionView,
108
+ request: PanelAppBridgeRequest,
109
+ ): Promise<ServiceActionInvokeResultView> => {
110
+ const actionId = this.requireActionId(request);
111
+ try {
112
+ return await nextclawClient.serviceApps.invokeServiceAction(
113
+ actionId,
114
+ request.payload?.input,
115
+ { bridgeSessionToken: session.token },
116
+ );
117
+ } catch (error) {
118
+ if (!(error instanceof NextClawClientError) || error.code !== 'AUTHORIZATION_REQUIRED') {
119
+ throw error;
120
+ }
121
+ await this.confirmAndGrant(session, actionId, request.payload?.input);
122
+ return await nextclawClient.serviceApps.invokeServiceAction(
123
+ actionId,
124
+ request.payload?.input,
125
+ { bridgeSessionToken: session.token },
126
+ );
127
+ }
128
+ };
129
+
130
+ private confirmAndGrant = async (
131
+ session: PanelAppBridgeSessionView,
132
+ actionId: string,
133
+ input?: Record<string, unknown>,
134
+ ): Promise<ServiceActionGrantView> => {
135
+ const action = await this.findAction(session, actionId);
136
+ const allowed = await this.authorizationManager.requestAuthorization({
137
+ panelAppId: session.panelAppId,
138
+ actionId,
139
+ actionTitle: action?.title,
140
+ actionDescription: action?.description,
141
+ risk: action?.risk,
142
+ inputPreview: this.createInputPreview(input),
143
+ });
144
+ if (!allowed) {
145
+ throw new NextClawClientError({
146
+ code: 'AUTHORIZATION_REJECTED',
147
+ message: `Permission rejected for ${actionId}.`,
148
+ });
149
+ }
150
+ return await nextclawClient.serviceApps.grantServiceAction(
151
+ actionId,
152
+ { bridgeSessionToken: session.token },
153
+ );
154
+ };
155
+
156
+ private findAction = async (
157
+ session: PanelAppBridgeSessionView,
158
+ actionId: string,
159
+ ): Promise<ServiceActionListView['actions'][number] | undefined> => {
160
+ const actions = await nextclawClient.serviceApps.listServiceActions({
161
+ bridgeSessionToken: session.token,
162
+ });
163
+ return actions.actions.find((action) => action.id === actionId);
164
+ };
165
+
166
+ private createInputPreview = (input: Record<string, unknown> | undefined): string | undefined => {
167
+ if (!input || Object.keys(input).length === 0) {
168
+ return undefined;
169
+ }
170
+ try {
171
+ const value = JSON.stringify(input, null, 2);
172
+ return value.length > 500 ? `${value.slice(0, 500)}...` : value;
173
+ } catch {
174
+ return '[unserializable input]';
175
+ }
176
+ };
177
+
178
+ private getSession = async (
179
+ params: DocBrowserIframeMessageParams,
180
+ ): Promise<PanelAppBridgeSessionView> => {
181
+ const panelAppId = this.readPanelAppId(params.tab.currentUrl);
182
+ const key = `${params.tab.id}:${panelAppId}`;
183
+ let session = this.sessions.get(key);
184
+ if (!session) {
185
+ session = nextclawClient.panelApps.createBridgeSession({
186
+ panelAppId,
187
+ tabId: params.tab.id,
188
+ });
189
+ this.sessions.set(key, session);
190
+ }
191
+ return await session;
192
+ };
193
+
194
+ private readPanelAppId = (url: string): string => {
195
+ const parsed = new URL(url, window.location.origin);
196
+ const match = /^\/api\/panel-apps\/([^/]+)\/content$/.exec(parsed.pathname);
197
+ if (!match?.[1]) {
198
+ throw new Error('Current tab is not a Panel App.');
199
+ }
200
+ return decodeURIComponent(match[1]);
201
+ };
202
+
203
+ private requireActionId = (request: PanelAppBridgeRequest): string => {
204
+ const actionId = request.payload?.actionId?.trim();
205
+ if (!actionId) {
206
+ throw new Error('Panel bridge actionId is required.');
207
+ }
208
+ return actionId;
209
+ };
210
+
211
+ private postResponse = (
212
+ params: DocBrowserIframeMessageParams,
213
+ response: PanelAppBridgeResponse,
214
+ ): void => {
215
+ params.iframe?.contentWindow?.postMessage(response, '*');
216
+ };
217
+
218
+ private toBridgeError = (
219
+ error: unknown,
220
+ ): { code?: string; message: string; details?: Record<string, unknown> } => {
221
+ if (error instanceof NextClawClientError) {
222
+ return {
223
+ code: error.code,
224
+ message: error.message,
225
+ details: error.details,
226
+ };
227
+ }
228
+ return {
229
+ message: error instanceof Error ? error.message : String(error),
230
+ };
231
+ };
232
+
233
+ private isBridgeRequest = (value: unknown): value is PanelAppBridgeRequest => {
234
+ if (!value || typeof value !== 'object') {
235
+ return false;
236
+ }
237
+ const candidate = value as Partial<PanelAppBridgeRequest>;
238
+ return (
239
+ candidate.type === 'nextclaw:panel-app-service-actions:request' &&
240
+ typeof candidate.requestId === 'string' &&
241
+ (candidate.method === 'invoke' ||
242
+ candidate.method === 'list' ||
243
+ candidate.method === 'requestGrant' ||
244
+ candidate.method === 'revokeGrant')
245
+ );
246
+ };
247
+ }
@@ -0,0 +1,23 @@
1
+ import {
2
+ createAppsPanelUrl,
3
+ getAppsPanelTabFromUrl,
4
+ } from '@/features/panel-apps/utils/panel-app-doc-browser.utils';
5
+
6
+ describe('panel app doc browser URL helpers', () => {
7
+ it('uses the root apps URL for the default panel apps tab', () => {
8
+ expect(createAppsPanelUrl('panel-apps')).toBe('nextclaw://apps');
9
+ expect(getAppsPanelTabFromUrl('nextclaw://apps')).toBe('panel-apps');
10
+ });
11
+
12
+ it('round-trips the service apps tab through the hidden apps URL', () => {
13
+ const url = createAppsPanelUrl('service-apps');
14
+
15
+ expect(url).toBe('nextclaw://apps?tab=service-apps');
16
+ expect(getAppsPanelTabFromUrl(url)).toBe('service-apps');
17
+ });
18
+
19
+ it('falls back to panel apps for unknown URL state', () => {
20
+ expect(getAppsPanelTabFromUrl('nextclaw://apps?tab=unknown')).toBe('panel-apps');
21
+ expect(getAppsPanelTabFromUrl('not a url')).toBe('panel-apps');
22
+ });
23
+ });
@@ -1,15 +1,16 @@
1
- import { AppWindow } from 'lucide-react';
1
+ import { AppWindow, Boxes } from 'lucide-react';
2
2
  import type { DocBrowserContextValue } from '@/shared/components/doc-browser/doc-browser-context';
3
3
  import type { DocBrowserCustomTabRenderers } from '@/shared/components/doc-browser/doc-browser-renderer.types';
4
+ import { getPresenter } from '@/app/presenters/app.presenter';
5
+ import { AppsPanel, type AppsPanelTab } from '@/features/apps';
4
6
  import { PanelAppToolbar } from '@/features/panel-apps/components/panel-app-toolbar';
5
- import { PanelAppsList } from '@/features/panel-apps/components/panel-apps-list';
6
7
  import { t } from '@/shared/lib/i18n';
7
8
 
8
- export const PANEL_APPS_LIST_TAB_KIND = 'panel-apps';
9
+ export const APPS_TAB_KIND = 'apps';
9
10
  export const PANEL_APP_TAB_KIND = 'panel-app';
10
- const PANEL_APPS_LIST_URL = 'nextclaw://panel-apps';
11
+ const APPS_URL = 'nextclaw://apps';
12
+ const DEFAULT_APPS_PANEL_TAB: AppsPanelTab = 'panel-apps';
11
13
  const PANEL_APP_IFRAME_SANDBOX = [
12
- 'allow-same-origin',
13
14
  'allow-scripts',
14
15
  'allow-forms',
15
16
  'allow-modals',
@@ -20,20 +21,44 @@ const PANEL_APP_IFRAME_SANDBOX = [
20
21
  'allow-presentation',
21
22
  ].join(' ');
22
23
 
23
- export function openPanelApps(docBrowser: Pick<DocBrowserContextValue, 'open'>): void {
24
- docBrowser.open(PANEL_APPS_LIST_URL, {
25
- kind: PANEL_APPS_LIST_TAB_KIND,
26
- title: t('panelAppsTitle'),
27
- dedupeKey: 'panel-apps:list',
24
+ function isAppsPanelTab(value: unknown): value is AppsPanelTab {
25
+ return value === 'panel-apps' || value === 'service-apps';
26
+ }
27
+
28
+ export function createAppsPanelUrl(tab: AppsPanelTab = DEFAULT_APPS_PANEL_TAB): string {
29
+ return tab === DEFAULT_APPS_PANEL_TAB ? APPS_URL : `${APPS_URL}?tab=${tab}`;
30
+ }
31
+
32
+ export function getAppsPanelTabFromUrl(url: string): AppsPanelTab {
33
+ try {
34
+ const tab = new URL(url).searchParams.get('tab');
35
+ return isAppsPanelTab(tab) ? tab : DEFAULT_APPS_PANEL_TAB;
36
+ } catch {
37
+ return DEFAULT_APPS_PANEL_TAB;
38
+ }
39
+ }
40
+
41
+ export function openApps(docBrowser: Pick<DocBrowserContextValue, 'open'>): void {
42
+ docBrowser.open(createAppsPanelUrl(), {
43
+ kind: APPS_TAB_KIND,
44
+ title: t('appsTitle'),
45
+ dedupeKey: 'apps',
28
46
  });
29
47
  }
30
48
 
31
49
  export const PANEL_APPS_DOC_BROWSER_RENDERERS: DocBrowserCustomTabRenderers = {
32
- [PANEL_APPS_LIST_TAB_KIND]: {
33
- getTitle: () => t('panelAppsTitle'),
34
- renderIcon: () => <AppWindow className="w-4 h-4 text-primary shrink-0" />,
35
- renderContent: ({ open }) => (
36
- <PanelAppsList
50
+ [APPS_TAB_KIND]: {
51
+ getTitle: () => t('appsTitle'),
52
+ renderIcon: () => <Boxes className="w-4 h-4 text-primary shrink-0" />,
53
+ renderContent: ({ currentUrl, open }) => (
54
+ <AppsPanel
55
+ activeTab={getAppsPanelTabFromUrl(currentUrl)}
56
+ onActiveTabChange={(tab) => open(createAppsPanelUrl(tab), {
57
+ activate: false,
58
+ kind: APPS_TAB_KIND,
59
+ title: t('appsTitle'),
60
+ dedupeKey: 'apps',
61
+ })}
37
62
  onOpenPanelApp={(entry) => open(entry.contentPath, {
38
63
  kind: PANEL_APP_TAB_KIND,
39
64
  title: entry.title,
@@ -45,10 +70,11 @@ export const PANEL_APPS_DOC_BROWSER_RENDERERS: DocBrowserCustomTabRenderers = {
45
70
  [PANEL_APP_TAB_KIND]: {
46
71
  getIframeSandbox: () => PANEL_APP_IFRAME_SANDBOX,
47
72
  getTitle: (tab) => tab.title || t('panelAppsTitle'),
73
+ onIframeMessage: (params) => getPresenter().panelAppBridgeManager.handleIframeMessage(params),
48
74
  renderIcon: () => <AppWindow className="w-4 h-4 text-primary shrink-0" />,
49
75
  renderToolbar: ({ open, refreshIframe }) => (
50
76
  <PanelAppToolbar
51
- onOpenPanelApps={() => openPanelApps({ open })}
77
+ onOpenApps={() => openApps({ open })}
52
78
  onRefresh={refreshIframe}
53
79
  />
54
80
  ),
@@ -20,8 +20,8 @@ vi.mock("@/features/remote/hooks/use-remote-access", () => ({
20
20
  useRemoteStatus: () => mocks.statusQuery,
21
21
  }));
22
22
 
23
- vi.mock("@/app/components/app-manager-provider", () => ({
24
- useAppManager: () => ({
23
+ vi.mock("@/app/components/app-presenter-provider", () => ({
24
+ useAppPresenter: () => ({
25
25
  remoteAccessManager: {
26
26
  reauthorizeRemoteAccess: mocks.reauthorizeRemoteAccess,
27
27
  repairRemoteAccess: mocks.repairRemoteAccess,
@@ -9,7 +9,7 @@ import {
9
9
  } from "@/shared/components/ui/card";
10
10
  import { NoticeCard } from "@/shared/components/ui/notice-card";
11
11
  import { StatusDot } from "@/shared/components/ui/status-dot";
12
- import { useAppManager } from "@/app/components/app-manager-provider";
12
+ import { useAppPresenter } from "@/app/components/app-presenter-provider";
13
13
  import {
14
14
  buildRemoteAccessFeedbackView,
15
15
  resolveRemoteWebBase,
@@ -45,7 +45,7 @@ function KeyValueRow(props: {
45
45
  }
46
46
 
47
47
  export function RemoteAccessPage() {
48
- const manager = useAppManager();
48
+ const presenter = useAppPresenter();
49
49
  const remoteStatus = useRemoteStatus();
50
50
  const status = remoteStatus.data;
51
51
  const actionLabel = useRemoteAccessStore((state) => state.actionLabel);
@@ -64,8 +64,8 @@ export function RemoteAccessPage() {
64
64
  const { hero: heroView, issueHint } = feedbackView;
65
65
 
66
66
  useEffect(() => {
67
- manager.remoteAccessManager.syncStatus(status);
68
- }, [manager, status]);
67
+ presenter.remoteAccessManager.syncStatus(status);
68
+ }, [presenter, status]);
69
69
 
70
70
  if (remoteStatus.isLoading && !status) {
71
71
  return <div className="p-8 text-gray-400">{t("remoteLoading")}</div>;
@@ -117,18 +117,18 @@ export function RemoteAccessPage() {
117
117
  <Button
118
118
  onClick={() => {
119
119
  if (feedbackView.primaryAction?.kind === "reauthorize") {
120
- void manager.remoteAccessManager.reauthorizeRemoteAccess(
120
+ void presenter.remoteAccessManager.reauthorizeRemoteAccess(
121
121
  status,
122
122
  );
123
123
  return;
124
124
  }
125
125
  if (feedbackView.primaryAction?.kind === "repair") {
126
- void manager.remoteAccessManager.repairRemoteAccess(
126
+ void presenter.remoteAccessManager.repairRemoteAccess(
127
127
  status,
128
128
  );
129
129
  return;
130
130
  }
131
- void manager.remoteAccessManager.enableRemoteAccess(
131
+ void presenter.remoteAccessManager.enableRemoteAccess(
132
132
  status,
133
133
  );
134
134
  }}
@@ -143,7 +143,7 @@ export function RemoteAccessPage() {
143
143
 
144
144
  <Button
145
145
  variant="outline"
146
- onClick={() => void manager.accountManager.openNextClawWeb()}
146
+ onClick={() => void presenter.accountManager.openNextClawWeb()}
147
147
  disabled={busy || !canOpenDeviceList}
148
148
  >
149
149
  <SquareArrowOutUpRight className="mr-2 h-4 w-4" />
@@ -154,7 +154,7 @@ export function RemoteAccessPage() {
154
154
  <Button
155
155
  variant="outline"
156
156
  onClick={() =>
157
- void manager.remoteAccessManager.disableRemoteAccess(
157
+ void presenter.remoteAccessManager.disableRemoteAccess(
158
158
  status,
159
159
  )
160
160
  }
@@ -1,17 +1,13 @@
1
1
  import { controlRemoteService, fetchRemoteDoctor, updateRemoteSettings } from '@/shared/lib/api';
2
2
  import type { RemoteAccessView } from '@/shared/lib/api';
3
- import type { AccountManager, AccountPendingAction } from '@/features/account';
3
+ import type { AccountManager } from '@/features/account';
4
4
  import { t } from '@/shared/lib/i18n';
5
5
  import { toast } from 'sonner';
6
6
  import { refreshRemoteStatus } from '@/features/remote/services/remote-access-query.service';
7
7
  import { useRemoteAccessStore } from '@/features/remote/stores/remote-access.store';
8
8
 
9
9
  export class RemoteAccessManager {
10
- private accountManager: AccountManager | null = null;
11
-
12
- bindAccountManager = (accountManager: AccountManager) => {
13
- this.accountManager = accountManager;
14
- };
10
+ constructor(private readonly params: { accountManager: AccountManager }) {}
15
11
 
16
12
  syncStatus = (status: RemoteAccessView | undefined) => {
17
13
  if (!status) {
@@ -43,14 +39,8 @@ export class RemoteAccessManager {
43
39
  enableRemoteAccess = async (status: RemoteAccessView | undefined) => {
44
40
  const currentStatus = status ?? (await refreshRemoteStatus());
45
41
  const draft = useRemoteAccessStore.getState();
46
- if (!currentStatus.account.loggedIn) {
47
- await this.accountManager?.ensureSignedIn({
48
- pendingAction: { type: 'enable-remote' },
49
- apiBase: draft.platformApiBase
50
- });
51
- return;
52
- }
53
- await this.applyEnabledState(true, currentStatus);
42
+ const readyStatus = await this.ensureRemoteAccount(currentStatus, draft.platformApiBase);
43
+ await this.applyEnabledState(true, readyStatus);
54
44
  };
55
45
 
56
46
  disableRemoteAccess = async (status: RemoteAccessView | undefined) => {
@@ -60,13 +50,8 @@ export class RemoteAccessManager {
60
50
 
61
51
  repairRemoteAccess = async (status: RemoteAccessView | undefined) => {
62
52
  const currentStatus = status ?? (await refreshRemoteStatus());
63
- if (!currentStatus.account.loggedIn) {
64
- await this.accountManager?.ensureSignedIn({
65
- pendingAction: { type: 'enable-remote' }
66
- });
67
- return;
68
- }
69
- const action = currentStatus.service.running ? 'restart' : 'start';
53
+ const readyStatus = await this.ensureRemoteAccount(currentStatus);
54
+ const action = readyStatus.service.running ? 'restart' : 'start';
70
55
  await this.runManagedAction({
71
56
  actionLabel: action === 'restart' ? t('remoteActionRestarting') : t('remoteActionStarting'),
72
57
  job: async () => {
@@ -80,11 +65,11 @@ export class RemoteAccessManager {
80
65
 
81
66
  reauthorizeRemoteAccess = async (status: RemoteAccessView | undefined) => {
82
67
  const currentStatus = status ?? (await refreshRemoteStatus());
83
- await this.accountManager?.startBrowserSignIn({
68
+ const readyStatus = await this.params.accountManager.startBrowserSignInAndWait({
84
69
  status: currentStatus,
85
- apiBase: useRemoteAccessStore.getState().platformApiBase,
86
- pendingAction: { type: 'repair-remote' }
70
+ apiBase: useRemoteAccessStore.getState().platformApiBase
87
71
  });
72
+ await this.repairRemoteAccess(readyStatus);
88
73
  };
89
74
 
90
75
  saveAdvancedSettings = async (status: RemoteAccessView | undefined) => {
@@ -128,19 +113,6 @@ export class RemoteAccessManager {
128
113
  await this.runServiceAction('stop', t('remoteActionStopping'));
129
114
  };
130
115
 
131
- resumePendingActionAfterSignIn = async (action: AccountPendingAction, status: RemoteAccessView) => {
132
- if (!action) {
133
- return;
134
- }
135
- if (action.type === 'enable-remote') {
136
- await this.applyEnabledState(true, status);
137
- return;
138
- }
139
- if (action.type === 'repair-remote') {
140
- await this.repairRemoteAccess(status);
141
- }
142
- };
143
-
144
116
  private applyEnabledState = async (enabled: boolean, status: RemoteAccessView) => {
145
117
  const draft = useRemoteAccessStore.getState();
146
118
  await this.runManagedAction({
@@ -166,6 +138,16 @@ export class RemoteAccessManager {
166
138
  });
167
139
  };
168
140
 
141
+ private ensureRemoteAccount = async (status: RemoteAccessView, apiBase?: string) => {
142
+ if (status.account.loggedIn) {
143
+ return status;
144
+ }
145
+ return await this.params.accountManager.ensureSignedIn({
146
+ apiBase,
147
+ status
148
+ });
149
+ };
150
+
169
151
  private runServiceAction = async (action: 'start' | 'restart' | 'stop', actionLabel: string) => {
170
152
  await this.runManagedAction({
171
153
  actionLabel,
@@ -191,11 +173,12 @@ export class RemoteAccessManager {
191
173
  job: () => Promise<void>;
192
174
  successMessage?: string;
193
175
  }) => {
194
- useRemoteAccessStore.getState().beginAction(params.actionLabel);
176
+ const { actionLabel, job, successMessage } = params;
177
+ useRemoteAccessStore.getState().beginAction(actionLabel);
195
178
  try {
196
- await params.job();
197
- if (params.successMessage) {
198
- toast.success(params.successMessage);
179
+ await job();
180
+ if (successMessage) {
181
+ toast.success(successMessage);
199
182
  }
200
183
  } catch (error) {
201
184
  const message = error instanceof Error ? error.message : t('error');
@@ -0,0 +1,78 @@
1
+ import { ShieldAlert } from 'lucide-react';
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogDescription,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from '@/shared/components/ui/dialog';
10
+ import { Button } from '@/shared/components/ui/button';
11
+ import { t } from '@/shared/lib/i18n';
12
+ import { useServiceActionAuthorizationStore } from '@/features/service-apps/stores/service-action-authorization.store';
13
+
14
+ export function ServiceActionAuthorizationDialog() {
15
+ const pending = useServiceActionAuthorizationStore((state) => state.pending);
16
+ const resolveAuthorization = useServiceActionAuthorizationStore(
17
+ (state) => state.resolveAuthorization,
18
+ );
19
+
20
+ return (
21
+ <Dialog
22
+ open={Boolean(pending)}
23
+ onOpenChange={(open) => {
24
+ if (!open) {
25
+ resolveAuthorization(false);
26
+ }
27
+ }}
28
+ >
29
+ <DialogContent className="[&>:last-child]:hidden">
30
+ <DialogHeader>
31
+ <DialogTitle className="flex items-center gap-2">
32
+ <ShieldAlert className="h-4 w-4 text-amber-600" />
33
+ {t('serviceActionAuthorizationTitle')}
34
+ </DialogTitle>
35
+ <DialogDescription>
36
+ {t('serviceActionAuthorizationDescription')}
37
+ </DialogDescription>
38
+ </DialogHeader>
39
+ {pending ? (
40
+ <div className="space-y-3 rounded-md border border-gray-100 bg-gray-50 p-3 text-sm">
41
+ <AuthorizationField label={t('serviceActionAuthorizationSource')} value={pending.panelAppId} />
42
+ <AuthorizationField label={t('serviceActionAuthorizationAction')} value={pending.actionTitle ?? pending.actionId} />
43
+ {pending.actionDescription ? (
44
+ <AuthorizationField label={t('serviceActionAuthorizationPurpose')} value={pending.actionDescription} />
45
+ ) : null}
46
+ <AuthorizationField label={t('serviceActionAuthorizationRisk')} value={pending.risk ?? 'dangerous'} />
47
+ {pending.inputPreview ? (
48
+ <AuthorizationField label={t('serviceActionAuthorizationInput')} value={pending.inputPreview} />
49
+ ) : null}
50
+ </div>
51
+ ) : null}
52
+ <DialogFooter className="gap-2 sm:gap-0">
53
+ <Button type="button" variant="outline" onClick={() => resolveAuthorization(false)}>
54
+ {t('serviceActionAuthorizationReject')}
55
+ </Button>
56
+ <Button type="button" onClick={() => resolveAuthorization(true)}>
57
+ {t('serviceActionAuthorizationAllow')}
58
+ </Button>
59
+ </DialogFooter>
60
+ </DialogContent>
61
+ </Dialog>
62
+ );
63
+ }
64
+
65
+ function AuthorizationField({
66
+ label,
67
+ value,
68
+ }: {
69
+ label: string;
70
+ value: string;
71
+ }) {
72
+ return (
73
+ <div className="grid grid-cols-[5rem_minmax(0,1fr)] gap-3">
74
+ <div className="text-xs font-medium text-gray-500">{label}</div>
75
+ <div className="min-w-0 break-words text-xs text-gray-900">{value}</div>
76
+ </div>
77
+ );
78
+ }