@nextclaw/ui 0.13.8 → 0.13.10-beta.0

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 (93) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assets/{api-BCDb13Uv.js → api-CA2udGyv.js} +5 -5
  3. package/dist/assets/{app-presenter-provider-C-QMjK7m.js → app-presenter-provider-B9ChYida.js} +2 -2
  4. package/dist/assets/arrow-left-CI0Ocu9p.js +1 -0
  5. package/dist/assets/channels-list-page-PrT4a9g4.js +8 -0
  6. package/dist/assets/chat-page-CImtIhmN.js +105 -0
  7. package/dist/assets/{config-split-page-BwhGqmUS.js → config-split-page-bO0K6uQv.js} +1 -1
  8. package/dist/assets/{confirm-dialog-DBkKcQ1q.js → confirm-dialog-l4FYdzga.js} +1 -1
  9. package/dist/assets/{desktop-update-config-BE9KjiXi.js → desktop-update-config-p3rJIgRg.js} +1 -1
  10. package/dist/assets/{dist-BI0bX8jv.js → dist-DQoV-62W.js} +1 -1
  11. package/dist/assets/{dist-C8JRt-tf.js → dist-T6rf86Nf.js} +1 -1
  12. package/dist/assets/{doc-browser-eqdNEerh.js → doc-browser-CTFB6ACN.js} +1 -1
  13. package/dist/assets/{doc-browser-context-VHUOIUXL.js → doc-browser-context-CP2tI-Br.js} +1 -1
  14. package/dist/assets/doc-browser-yi02ASOL.js +1 -0
  15. package/dist/assets/{ellipsis-C9VB2CxT.js → ellipsis-B7CbNPNr.js} +1 -1
  16. package/dist/assets/{es2015-BCw3Q7nY.js → es2015-BSPOGpH4.js} +1 -1
  17. package/dist/assets/{external-link-Bp7_4s9t.js → external-link-CefT35zP.js} +1 -1
  18. package/dist/assets/{index-DTdPDqto.js → index-DFC_DdT1.js} +5 -5
  19. package/dist/assets/{key-round-Ccy6oND3.js → key-round-BpM3GZn4.js} +1 -1
  20. package/dist/assets/{loader-circle-OpH4EErP.js → loader-circle-_edjOFsw.js} +1 -1
  21. package/dist/assets/mcp-marketplace-page-DNHSmfDf.js +1 -0
  22. package/dist/assets/{mcp-marketplace-page-B-GxKOqS.js → mcp-marketplace-page-DhqcnhZc.js} +2 -2
  23. package/dist/assets/{model-config-BXPnV_7n.js → model-config-C9ydDDFh.js} +1 -1
  24. package/dist/assets/{notice-card-Dsbkl7u7.js → notice-card-BfXlhPjX.js} +1 -1
  25. package/dist/assets/play-DD0bg50i.js +1 -0
  26. package/dist/assets/plus-zZ2GC_AJ.js +1 -0
  27. package/dist/assets/{popover-BTLZE5Iz.js → popover-D_pJfOlM.js} +1 -1
  28. package/dist/assets/{provider-scoped-model-input-DKI9KN7N.js → provider-scoped-model-input-CkeEyTfO.js} +1 -1
  29. package/dist/assets/providers-list-9eOBQFUn.js +1 -0
  30. package/dist/assets/{react-BqS8oMyM.js → react-Bl8iJUcF.js} +1 -1
  31. package/dist/assets/{refresh-cw-BIaj7txb.js → refresh-cw-CqfS1EhK.js} +1 -1
  32. package/dist/assets/remote-fGlxrJI_.js +1 -0
  33. package/dist/assets/rotate-cw-C7EvaSlb.js +1 -0
  34. package/dist/assets/{runtime-config-page-DX_wSK1J.js → runtime-config-page-B30PC6bx.js} +1 -1
  35. package/dist/assets/{save-BEoORten.js → save-CQERCODg.js} +1 -1
  36. package/dist/assets/search-C0b2yzmt.js +1 -0
  37. package/dist/assets/{search-config-DA5By-tE.js → search-config-RN01uPJQ.js} +1 -1
  38. package/dist/assets/{secrets-config-eDxG7iN4.js → secrets-config-CTZ_TGoA.js} +2 -2
  39. package/dist/assets/{status-dot-oWBjTovo.js → status-dot-DY0xUUvK.js} +1 -1
  40. package/dist/assets/{tabs-CGaY0BVO.js → tabs-C77i1w90.js} +1 -1
  41. package/dist/assets/{tabs-custom-D2fPYb4G.js → tabs-custom-CFpR54-m.js} +1 -1
  42. package/dist/assets/{tag-chip-TVkgofS1.js → tag-chip-Dnb2VuWh.js} +1 -1
  43. package/dist/assets/{tooltip-BwHPKBv9.js → tooltip-fs7VQG7d.js} +1 -1
  44. package/dist/assets/{trash-2-D3psAGrz.js → trash-2-Bb8mTTLl.js} +1 -1
  45. package/dist/assets/x-BV4iiea8.js +1 -0
  46. package/dist/index.html +24 -24
  47. package/package.json +8 -8
  48. package/src/app/components/layout/runtime-status-entry.test.tsx +5 -0
  49. package/src/app/components/layout/runtime-status-entry.tsx +8 -0
  50. package/src/features/chat/components/conversation/chat-message-list.container.tsx +0 -6
  51. package/src/features/chat/components/workspace/session-cron-job-content.tsx +3 -3
  52. package/src/features/chat/hooks/use-chat-session-update.test.tsx +2 -1
  53. package/src/features/chat/managers/chat-session-preference-sync.manager.test.ts +2 -1
  54. package/src/features/chat/utils/chat-input-bar.utils.ts +0 -2
  55. package/src/features/chat/utils/chat-session-preference-governance.utils.ts +4 -1
  56. package/src/features/marketplace/components/mcp/mcp-marketplace-page.test.tsx +2 -1
  57. package/src/features/panel-apps/components/panel-app-list-item.test.tsx +4 -0
  58. package/src/features/panel-apps/components/panel-apps-list.tsx +27 -1
  59. package/src/features/panel-apps/hooks/use-panel-apps.ts +10 -0
  60. package/src/features/panel-apps/managers/panel-app-bridge.manager.test.ts +124 -140
  61. package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +71 -53
  62. package/src/features/panel-apps/utils/panel-app-view.utils.test.ts +4 -0
  63. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.test.ts +4 -0
  64. package/src/features/service-apps/components/service-action-authorization-dialog.tsx +38 -5
  65. package/src/features/service-apps/components/service-apps-panel.tsx +3 -3
  66. package/src/features/service-apps/stores/service-action-authorization.store.ts +7 -3
  67. package/src/features/system-status/components/desktop-update-config.test.tsx +2 -1
  68. package/src/features/system-status/hooks/use-system-status.ts +1 -4
  69. package/src/features/system-status/managers/system-status.manager.bootstrap-polling.test.ts +137 -100
  70. package/src/features/system-status/managers/system-status.manager.ts +38 -20
  71. package/src/shared/components/cron-config.tsx +1 -5
  72. package/src/shared/lib/api/index.ts +1 -1
  73. package/src/shared/lib/api/{ncp-session-query-cache.test.ts → ncp-session-query-cache.utils.test.ts} +23 -0
  74. package/src/shared/lib/api/{ncp-session-query-cache.ts → ncp-session-query-cache.utils.ts} +9 -11
  75. package/src/shared/lib/api/types.ts +4 -4
  76. package/src/shared/lib/cadence/index.ts +7 -0
  77. package/src/shared/lib/cadence/services/adaptive-cadence.service.test.ts +95 -0
  78. package/src/shared/lib/cadence/services/adaptive-cadence.service.ts +120 -0
  79. package/src/shared/lib/cron/cron-job-view.utils.ts +0 -9
  80. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +9 -3
  81. package/src/shared/lib/i18n/runtime/i18n-language-owner.test.ts +3 -2
  82. package/dist/assets/arrow-left-rIXFA2Zy.js +0 -1
  83. package/dist/assets/channels-list-page-DnqwsKGe.js +0 -8
  84. package/dist/assets/chat-page-CsQx20xL.js +0 -105
  85. package/dist/assets/doc-browser-lpF_pyx4.js +0 -1
  86. package/dist/assets/mcp-marketplace-page-Dm-0-w51.js +0 -1
  87. package/dist/assets/play-CvgYhNbo.js +0 -1
  88. package/dist/assets/plus-DHcP-K1U.js +0 -1
  89. package/dist/assets/providers-list-Bko6B7dE.js +0 -1
  90. package/dist/assets/remote-DHn_b8QU.js +0 -1
  91. package/dist/assets/rotate-cw-DMqSkRJQ.js +0 -1
  92. package/dist/assets/search-COFh_SFI.js +0 -1
  93. package/dist/assets/x-CHAxzyp2.js +0 -1
@@ -1,7 +1,8 @@
1
1
  import { useMemo, useState, type ReactNode } from 'react';
2
2
  import { HelpCircle, RefreshCw } from 'lucide-react';
3
+ import { useAppPresenter } from '@/app/components/app-presenter-provider';
3
4
  import { PanelAppListItem } from '@/features/panel-apps/components/panel-app-list-item';
4
- import { useDeletePanelApp, usePanelApps, useRecordPanelAppOpened, useUpdatePanelAppPreferences } from '@/features/panel-apps/hooks/use-panel-apps';
5
+ import { useDeletePanelApp, useGrantPanelAppClient, usePanelApps, useRecordPanelAppOpened, useUpdatePanelAppPreferences } from '@/features/panel-apps/hooks/use-panel-apps';
5
6
  import { getPanelAppViewEntries } from '@/features/panel-apps/utils/panel-app-view.utils';
6
7
  import type { PanelAppViewMode } from '@/features/panel-apps/utils/panel-app-view.utils';
7
8
  import type { PanelAppEntryView } from '@/shared/lib/api';
@@ -20,6 +21,8 @@ export function PanelAppsList({
20
21
  const deletePanelApp = useDeletePanelApp();
21
22
  const updatePreferences = useUpdatePanelAppPreferences();
22
23
  const recordOpened = useRecordPanelAppOpened();
24
+ const grantClient = useGrantPanelAppClient();
25
+ const presenter = useAppPresenter();
23
26
  const [viewMode, setViewMode] = useState<PanelAppViewMode>('smart');
24
27
  const entries = useMemo(
25
28
  () => getPanelAppViewEntries(panelApps.data?.entries ?? [], viewMode),
@@ -27,6 +30,9 @@ export function PanelAppsList({
27
30
  );
28
31
 
29
32
  const openPanelApp = async (entry: PanelAppEntryView) => {
33
+ if (!(await ensurePanelAppClientGrant(entry))) {
34
+ return;
35
+ }
30
36
  try {
31
37
  onOpenPanelApp(await recordOpened.mutateAsync(entry.id));
32
38
  } catch {
@@ -34,6 +40,26 @@ export function PanelAppsList({
34
40
  }
35
41
  };
36
42
 
43
+ const ensurePanelAppClientGrant = async (entry: PanelAppEntryView): Promise<boolean> => {
44
+ if (!entry.clientDeclared || entry.clientGranted) {
45
+ return true;
46
+ }
47
+ const allowed = await presenter.serviceActionAuthorizationManager.requestAuthorization({
48
+ panelAppId: entry.appId,
49
+ actions: [{
50
+ actionId: 'nextclaw.client',
51
+ actionTitle: t('panelAppsClientGrantTitle'),
52
+ actionDescription: t('panelAppsClientGrantDescription'),
53
+ risk: 'dangerous',
54
+ }],
55
+ });
56
+ if (!allowed) {
57
+ return false;
58
+ }
59
+ await grantClient.mutateAsync(entry.appId);
60
+ return true;
61
+ };
62
+
37
63
  const toggleFavorite = (entry: PanelAppEntryView) => {
38
64
  updatePreferences.mutate({
39
65
  id: entry.id,
@@ -38,6 +38,16 @@ export function useRecordPanelAppOpened() {
38
38
  });
39
39
  }
40
40
 
41
+ export function useGrantPanelAppClient() {
42
+ const queryClient = useQueryClient();
43
+ return useMutation({
44
+ mutationFn: (appId: string) => nextclawClient.panelApps.grantClient(appId),
45
+ onSuccess: () => {
46
+ void queryClient.invalidateQueries({ queryKey: PANEL_APPS_QUERY_KEY });
47
+ },
48
+ });
49
+ }
50
+
41
51
  export function useDeletePanelApp() {
42
52
  const queryClient = useQueryClient();
43
53
  return useMutation({
@@ -5,10 +5,10 @@ import { PanelAppBridgeManager } from '@/features/panel-apps/managers/panel-app-
5
5
  import type { ServiceActionAuthorizationManager } from '@/features/service-apps';
6
6
 
7
7
  const mocks = vi.hoisted(() => ({
8
- createBridgeSession: vi.fn(),
9
8
  generateAgentObject: vi.fn(),
10
9
  grantAgentCapability: vi.fn(),
11
10
  grantServiceAction: vi.fn(),
11
+ grantServiceActions: vi.fn(),
12
12
  invokeServiceAction: vi.fn(),
13
13
  listServiceActions: vi.fn(),
14
14
  sendAgentMessage: vi.fn(),
@@ -18,13 +18,13 @@ const mocks = vi.hoisted(() => ({
18
18
  vi.mock('@/shared/lib/api', () => ({
19
19
  nextclawClient: {
20
20
  panelApps: {
21
- createBridgeSession: mocks.createBridgeSession,
22
21
  generateAgentObject: mocks.generateAgentObject,
23
22
  grantAgentCapability: mocks.grantAgentCapability,
24
23
  sendAgentMessage: mocks.sendAgentMessage,
25
24
  },
26
25
  serviceApps: {
27
26
  grantServiceAction: mocks.grantServiceAction,
27
+ grantServiceActions: mocks.grantServiceActions,
28
28
  invokeServiceAction: mocks.invokeServiceAction,
29
29
  listServiceActions: mocks.listServiceActions,
30
30
  revokeServiceAction: vi.fn(),
@@ -36,58 +36,32 @@ afterEach(() => {
36
36
  vi.clearAllMocks();
37
37
  });
38
38
 
39
- describe('PanelAppBridgeManager', () => {
40
- it('opens the authorization flow before retrying a protected service action', async () => {
41
- const manager = new PanelAppBridgeManager({
42
- requestAuthorization: mocks.requestAuthorization,
43
- } as unknown as ServiceActionAuthorizationManager);
44
- const postMessage = vi.fn();
45
- const contentWindow = { postMessage } as unknown as Window;
46
- const iframe = { contentWindow } as HTMLIFrameElement;
47
- const actionId = 'mood-tracker.saveMood';
48
- mocks.createBridgeSession.mockResolvedValue({
49
- expiresAt: '2026-05-28T00:00:00.000Z',
50
- id: 'session-1',
51
- panelAppId: 'mood-calendar',
52
- tabId: 'tab-1',
53
- token: 'token-1',
54
- });
55
- mocks.invokeServiceAction
56
- .mockRejectedValueOnce(new NextClawClientError({
57
- code: 'AUTHORIZATION_REQUIRED',
58
- message: 'authorization required',
59
- }))
60
- .mockResolvedValueOnce({ actionId, result: { saved: true } });
61
- mocks.listServiceActions.mockResolvedValue({
62
- actions: [{
63
- appId: 'mood-tracker',
64
- description: 'Save daily mood entries',
65
- id: actionId,
66
- name: 'saveMood',
67
- risk: 'write',
68
- title: 'Save mood',
69
- }],
70
- });
71
- mocks.requestAuthorization.mockResolvedValue(true);
72
- mocks.grantServiceAction.mockResolvedValue({
73
- actionId,
74
- caller: { surface: 'panel-app', appId: 'mood-calendar' },
75
- grantedAt: '2026-05-28T00:00:00.000Z',
76
- risk: 'write',
77
- });
39
+ function createManager(): PanelAppBridgeManager {
40
+ return new PanelAppBridgeManager({
41
+ requestAuthorization: mocks.requestAuthorization,
42
+ } as unknown as ServiceActionAuthorizationManager);
43
+ }
78
44
 
79
- manager.handleIframeMessage({
45
+ function createIframeHarness(manager: PanelAppBridgeManager) {
46
+ const postMessage = vi.fn();
47
+ const contentWindow = { postMessage } as unknown as Window;
48
+ const iframe = { contentWindow } as HTMLIFrameElement;
49
+ return {
50
+ postMessage,
51
+ send: (
52
+ data: Record<string, unknown>,
53
+ iframeInstanceId = 'tab-1:0:0',
54
+ ) => manager.handleIframeMessage({
80
55
  event: {
81
56
  data: {
82
- method: 'invoke',
83
- payload: { actionId, input: { mood: 'happy' } },
84
- requestId: 'request-1',
85
- type: 'nextclaw:panel-app-service-actions:request',
57
+ appId: 'mood-calendar',
58
+ runtimeToken: 'token-1',
59
+ ...data,
86
60
  },
87
61
  source: contentWindow,
88
62
  } as MessageEvent,
89
63
  iframe,
90
- iframeInstanceId: 'tab-1:0:0',
64
+ iframeInstanceId,
91
65
  tab: {
92
66
  currentUrl: '/api/panel-apps/mood-calendar/content',
93
67
  history: [],
@@ -97,20 +71,93 @@ describe('PanelAppBridgeManager', () => {
97
71
  navVersion: 0,
98
72
  title: 'Mood Calendar',
99
73
  },
74
+ }),
75
+ };
76
+ }
77
+
78
+ describe('PanelAppBridgeManager', () => {
79
+ it('authorizes missing service actions in one flow before retrying a protected action', async () => {
80
+ const manager = createManager();
81
+ const { postMessage, send } = createIframeHarness(manager);
82
+ const actionId = 'mood-tracker.saveMood';
83
+ mocks.invokeServiceAction
84
+ .mockRejectedValueOnce(new NextClawClientError({
85
+ code: 'AUTHORIZATION_REQUIRED',
86
+ message: 'authorization required',
87
+ }))
88
+ .mockResolvedValueOnce({ actionId, result: { saved: true } });
89
+ mocks.listServiceActions.mockResolvedValue({
90
+ actions: [
91
+ {
92
+ appId: 'mood-tracker',
93
+ description: 'Save daily mood entries',
94
+ grantState: 'not-granted',
95
+ id: actionId,
96
+ name: 'saveMood',
97
+ risk: 'write',
98
+ title: 'Save mood',
99
+ },
100
+ {
101
+ appId: 'mood-tracker',
102
+ description: 'Read mood history',
103
+ grantState: 'not-granted',
104
+ id: 'mood-tracker.listMoods',
105
+ name: 'listMoods',
106
+ risk: 'read',
107
+ title: 'List moods',
108
+ },
109
+ ],
110
+ });
111
+ mocks.requestAuthorization.mockResolvedValue(true);
112
+ mocks.grantServiceActions.mockResolvedValue({
113
+ grants: [
114
+ {
115
+ actionId,
116
+ caller: { surface: 'panel-app', appId: 'mood-calendar' },
117
+ grantedAt: '2026-05-28T00:00:00.000Z',
118
+ risk: 'write',
119
+ },
120
+ {
121
+ actionId: 'mood-tracker.listMoods',
122
+ caller: { surface: 'panel-app', appId: 'mood-calendar' },
123
+ grantedAt: '2026-05-28T00:00:00.000Z',
124
+ risk: 'read',
125
+ },
126
+ ],
127
+ });
128
+
129
+ send({
130
+ method: 'invoke',
131
+ payload: { actionId, input: { mood: 'happy' } },
132
+ requestId: 'request-1',
133
+ type: 'nextclaw:panel-app-service-actions:request',
100
134
  });
101
135
 
102
136
  await waitFor(() => expect(postMessage).toHaveBeenCalled());
103
137
 
104
138
  expect(mocks.requestAuthorization).toHaveBeenCalledWith(expect.objectContaining({
105
- actionId,
106
- actionTitle: 'Save mood',
139
+ actions: [
140
+ expect.objectContaining({
141
+ actionId,
142
+ actionTitle: 'Save mood',
143
+ risk: 'write',
144
+ }),
145
+ expect.objectContaining({
146
+ actionId: 'mood-tracker.listMoods',
147
+ actionTitle: 'List moods',
148
+ risk: 'read',
149
+ }),
150
+ ],
107
151
  inputPreview: expect.stringContaining('happy'),
108
152
  panelAppId: 'mood-calendar',
109
- risk: 'write',
110
153
  }));
111
- expect(mocks.grantServiceAction).toHaveBeenCalledWith(actionId, {
154
+ expect(mocks.grantServiceActions).toHaveBeenCalledWith([
155
+ actionId,
156
+ 'mood-tracker.listMoods',
157
+ ], {
112
158
  bridgeSessionToken: 'token-1',
113
159
  });
160
+ expect(mocks.grantServiceAction).not.toHaveBeenCalled();
114
161
  expect(mocks.invokeServiceAction).toHaveBeenCalledTimes(2);
115
162
  expect(postMessage).toHaveBeenCalledWith({
116
163
  data: { actionId, result: { saved: true } },
@@ -121,19 +168,8 @@ describe('PanelAppBridgeManager', () => {
121
168
  });
122
169
 
123
170
  it('opens the authorization flow before retrying a protected generateObject call', async () => {
124
- const manager = new PanelAppBridgeManager({
125
- requestAuthorization: mocks.requestAuthorization,
126
- } as unknown as ServiceActionAuthorizationManager);
127
- const postMessage = vi.fn();
128
- const contentWindow = { postMessage } as unknown as Window;
129
- const iframe = { contentWindow } as HTMLIFrameElement;
130
- mocks.createBridgeSession.mockResolvedValue({
131
- expiresAt: '2026-05-28T00:00:00.000Z',
132
- id: 'session-1',
133
- panelAppId: 'mood-calendar',
134
- tabId: 'tab-1',
135
- token: 'token-1',
136
- });
171
+ const manager = createManager();
172
+ const { postMessage, send } = createIframeHarness(manager);
137
173
  mocks.generateAgentObject
138
174
  .mockRejectedValueOnce(new NextClawClientError({
139
175
  code: 'AUTHORIZATION_REQUIRED',
@@ -147,41 +183,24 @@ describe('PanelAppBridgeManager', () => {
147
183
  grantedAt: '2026-05-28T00:00:00.000Z',
148
184
  });
149
185
 
150
- manager.handleIframeMessage({
151
- event: {
152
- data: {
153
- method: 'agent.generateObject',
154
- payload: {
155
- input: {
156
- peerId: 'mood-summary',
157
- prompt: 'summarize',
158
- schema: { type: 'object' },
159
- },
160
- },
161
- requestId: 'request-2',
162
- type: 'nextclaw:panel-app-service-actions:request',
186
+ send({
187
+ method: 'agent.generateObject',
188
+ payload: {
189
+ input: {
190
+ peerId: 'mood-summary',
191
+ prompt: 'summarize',
192
+ schema: { type: 'object' },
163
193
  },
164
- source: contentWindow,
165
- } as MessageEvent,
166
- iframe,
167
- iframeInstanceId: 'tab-1:0:0',
168
- tab: {
169
- currentUrl: '/api/panel-apps/mood-calendar/content',
170
- history: [],
171
- historyIndex: 0,
172
- id: 'tab-1',
173
- kind: 'content',
174
- navVersion: 0,
175
- title: 'Mood Calendar',
176
194
  },
195
+ requestId: 'request-2',
196
+ type: 'nextclaw:panel-app-service-actions:request',
177
197
  });
178
198
 
179
199
  await waitFor(() => expect(postMessage).toHaveBeenCalled());
180
200
 
181
201
  expect(mocks.requestAuthorization).toHaveBeenCalledWith(expect.objectContaining({
182
- actionId: 'agent:generateObject',
202
+ actions: [expect.objectContaining({ actionId: 'agent:generateObject' })],
183
203
  panelAppId: 'mood-calendar',
184
- risk: 'write',
185
204
  }));
186
205
  expect(mocks.grantAgentCapability).toHaveBeenCalledWith('agent:generateObject', {
187
206
  bridgeSessionToken: 'token-1',
@@ -195,69 +214,34 @@ describe('PanelAppBridgeManager', () => {
195
214
  }, '*');
196
215
  });
197
216
 
198
- it('creates a fresh bridge session for each iframe instance', async () => {
199
- const manager = new PanelAppBridgeManager({
200
- requestAuthorization: mocks.requestAuthorization,
201
- } as unknown as ServiceActionAuthorizationManager);
202
- const postMessage = vi.fn();
203
- const contentWindow = { postMessage } as unknown as Window;
204
- const iframe = { contentWindow } as HTMLIFrameElement;
205
- mocks.createBridgeSession
206
- .mockResolvedValueOnce({
207
- expiresAt: '2026-05-28T00:00:00.000Z',
208
- id: 'session-1',
209
- panelAppId: 'mood-calendar',
210
- tabId: 'tab-1',
211
- token: 'token-1',
212
- })
213
- .mockResolvedValueOnce({
214
- expiresAt: '2026-05-28T00:00:00.000Z',
215
- id: 'session-2',
216
- panelAppId: 'mood-calendar',
217
- tabId: 'tab-1',
218
- token: 'token-2',
219
- });
217
+ it('uses the injected runtime token without creating a bridge session', async () => {
218
+ const manager = createManager();
219
+ const { postMessage, send } = createIframeHarness(manager);
220
220
  mocks.listServiceActions.mockResolvedValue({ actions: [] });
221
221
 
222
- for (const [requestId, iframeInstanceId] of [
223
- ['request-1', 'tab-1:0:0'],
224
- ['request-2', 'tab-1:0:0'],
225
- ['request-3', 'tab-1:0:1'],
222
+ for (const [requestId, runtimeToken] of [
223
+ ['request-1', 'token-1'],
224
+ ['request-2', 'token-2'],
225
+ ['request-3', 'token-3'],
226
226
  ]) {
227
- manager.handleIframeMessage({
228
- event: {
229
- data: {
230
- method: 'list',
231
- requestId,
232
- type: 'nextclaw:panel-app-service-actions:request',
233
- },
234
- source: contentWindow,
235
- } as MessageEvent,
236
- iframe,
237
- iframeInstanceId,
238
- tab: {
239
- currentUrl: '/api/panel-apps/mood-calendar/content',
240
- history: [],
241
- historyIndex: 0,
242
- id: 'tab-1',
243
- kind: 'content',
244
- navVersion: 0,
245
- title: 'Mood Calendar',
246
- },
227
+ send({
228
+ method: 'list',
229
+ requestId,
230
+ runtimeToken,
231
+ type: 'nextclaw:panel-app-service-actions:request',
247
232
  });
248
233
  }
249
234
 
250
235
  await waitFor(() => expect(postMessage).toHaveBeenCalledTimes(3));
251
236
 
252
- expect(mocks.createBridgeSession).toHaveBeenCalledTimes(2);
253
237
  expect(mocks.listServiceActions).toHaveBeenNthCalledWith(1, {
254
238
  bridgeSessionToken: 'token-1',
255
239
  });
256
240
  expect(mocks.listServiceActions).toHaveBeenNthCalledWith(2, {
257
- bridgeSessionToken: 'token-1',
241
+ bridgeSessionToken: 'token-2',
258
242
  });
259
243
  expect(mocks.listServiceActions).toHaveBeenNthCalledWith(3, {
260
- bridgeSessionToken: 'token-2',
244
+ bridgeSessionToken: 'token-3',
261
245
  });
262
246
  });
263
247
  });
@@ -5,7 +5,6 @@ import type {
5
5
  PanelAppAgentGenerateObjectResultView,
6
6
  PanelAppAgentSendRequestView,
7
7
  PanelAppAgentSendResultView,
8
- PanelAppBridgeSessionView,
9
8
  PanelAppCapabilityGrantView,
10
9
  ServiceActionGrantView,
11
10
  ServiceActionInvokeResultView,
@@ -18,6 +17,8 @@ import { nextclawClient } from '@/shared/lib/api';
18
17
  type PanelAppBridgeRequest = {
19
18
  type: 'nextclaw:panel-app-service-actions:request';
20
19
  requestId: string;
20
+ appId: string;
21
+ runtimeToken: string;
21
22
  method:
22
23
  | 'agent.generateObject'
23
24
  | 'agent.send'
@@ -32,6 +33,11 @@ type PanelAppBridgeRequest = {
32
33
  };
33
34
  };
34
35
 
36
+ type PanelAppBridgeContext = {
37
+ appId: string;
38
+ token: string;
39
+ };
40
+
35
41
  type PanelAppBridgeResponse =
36
42
  | {
37
43
  type: 'nextclaw:panel-app-service-actions:response';
@@ -51,8 +57,6 @@ type PanelAppBridgeResponse =
51
57
  };
52
58
 
53
59
  export class PanelAppBridgeManager {
54
- private readonly sessions = new Map<string, Promise<PanelAppBridgeSessionView>>();
55
-
56
60
  constructor(private readonly authorizationManager: ServiceActionAuthorizationManager) {}
57
61
 
58
62
  handleIframeMessage = ({ event, iframe, iframeInstanceId, tab }: DocBrowserIframeMessageParams): void => {
@@ -70,7 +74,7 @@ export class PanelAppBridgeManager {
70
74
  request: PanelAppBridgeRequest,
71
75
  ): Promise<void> => {
72
76
  try {
73
- const session = await this.getSession(params);
77
+ const session = this.getBridgeContext(request);
74
78
  const data = await this.dispatchRequest(session, request);
75
79
  this.postResponse(params, {
76
80
  type: 'nextclaw:panel-app-service-actions:response',
@@ -89,7 +93,7 @@ export class PanelAppBridgeManager {
89
93
  };
90
94
 
91
95
  private dispatchRequest = async (
92
- session: PanelAppBridgeSessionView,
96
+ session: PanelAppBridgeContext,
93
97
  request: PanelAppBridgeRequest,
94
98
  ): Promise<
95
99
  | PanelAppAgentGenerateObjectResultView
@@ -124,7 +128,7 @@ export class PanelAppBridgeManager {
124
128
  };
125
129
 
126
130
  private invokeWithAuthorization = async (
127
- session: PanelAppBridgeSessionView,
131
+ session: PanelAppBridgeContext,
128
132
  request: PanelAppBridgeRequest,
129
133
  ): Promise<ServiceActionInvokeResultView> => {
130
134
  const actionId = this.requireActionId(request);
@@ -148,7 +152,7 @@ export class PanelAppBridgeManager {
148
152
  };
149
153
 
150
154
  private sendAgentMessageWithAuthorization = async (
151
- session: PanelAppBridgeSessionView,
155
+ session: PanelAppBridgeContext,
152
156
  request: PanelAppBridgeRequest,
153
157
  ): Promise<PanelAppAgentSendResultView> => {
154
158
  try {
@@ -169,7 +173,7 @@ export class PanelAppBridgeManager {
169
173
  };
170
174
 
171
175
  private generateAgentObjectWithAuthorization = async (
172
- session: PanelAppBridgeSessionView,
176
+ session: PanelAppBridgeContext,
173
177
  request: PanelAppBridgeRequest,
174
178
  ): Promise<PanelAppAgentGenerateObjectResultView> => {
175
179
  try {
@@ -190,17 +194,19 @@ export class PanelAppBridgeManager {
190
194
  };
191
195
 
192
196
  private confirmAndGrantAgentCapability = async (
193
- session: PanelAppBridgeSessionView,
197
+ session: PanelAppBridgeContext,
194
198
  capability: PanelAppAgentCapabilityView,
195
199
  ): Promise<PanelAppCapabilityGrantView> => {
196
200
  const allowed = await this.authorizationManager.requestAuthorization({
197
- panelAppId: session.panelAppId,
198
- actionId: capability,
199
- actionTitle: capability === 'agent:send' ? 'Send agent message' : 'Generate object',
200
- actionDescription: capability === 'agent:send'
201
- ? 'Send a message to a NextClaw Agent session.'
202
- : 'Send context to a NextClaw Agent session and receive a structured object.',
203
- risk: 'write',
201
+ panelAppId: session.appId,
202
+ actions: [{
203
+ actionId: capability,
204
+ actionTitle: capability === 'agent:send' ? 'Send agent message' : 'Generate object',
205
+ actionDescription: capability === 'agent:send'
206
+ ? 'Send a message to a NextClaw Agent session.'
207
+ : 'Send context to a NextClaw Agent session and receive a structured object.',
208
+ risk: 'write',
209
+ }],
204
210
  });
205
211
  if (!allowed) {
206
212
  throw new NextClawClientError({
@@ -215,17 +221,19 @@ export class PanelAppBridgeManager {
215
221
  };
216
222
 
217
223
  private confirmAndGrant = async (
218
- session: PanelAppBridgeSessionView,
224
+ session: PanelAppBridgeContext,
219
225
  actionId: string,
220
226
  input?: Record<string, unknown>,
221
227
  ): Promise<ServiceActionGrantView> => {
222
- const action = await this.findAction(session, actionId);
228
+ const actions = await this.listGrantCandidateActions(session, actionId);
223
229
  const allowed = await this.authorizationManager.requestAuthorization({
224
- panelAppId: session.panelAppId,
225
- actionId,
226
- actionTitle: action?.title,
227
- actionDescription: action?.description,
228
- risk: action?.risk,
230
+ panelAppId: session.appId,
231
+ actions: actions.map((action) => ({
232
+ actionId: action.id,
233
+ actionTitle: action.title,
234
+ actionDescription: action.description,
235
+ risk: action.risk,
236
+ })),
229
237
  inputPreview: this.createInputPreview(input),
230
238
  });
231
239
  if (!allowed) {
@@ -234,22 +242,48 @@ export class PanelAppBridgeManager {
234
242
  message: `Permission rejected for ${actionId}.`,
235
243
  });
236
244
  }
237
- return await nextclawClient.serviceApps.grantServiceAction(
238
- actionId,
245
+ const result = await nextclawClient.serviceApps.grantServiceActions(
246
+ actions.map((action) => action.id),
239
247
  { bridgeSessionToken: session.token },
240
248
  );
249
+ const grant = result.grants.find((entry) => entry.actionId === actionId) ?? result.grants[0];
250
+ if (!grant) {
251
+ throw new Error(`No permission grant returned for ${actionId}.`);
252
+ }
253
+ return grant;
241
254
  };
242
255
 
243
- private findAction = async (
244
- session: PanelAppBridgeSessionView,
256
+ private listGrantCandidateActions = async (
257
+ session: PanelAppBridgeContext,
245
258
  actionId: string,
246
- ): Promise<ServiceActionListView['actions'][number] | undefined> => {
247
- const actions = await nextclawClient.serviceApps.listServiceActions({
259
+ ): Promise<Array<ServiceActionListView['actions'][number]>> => {
260
+ const actionList = await nextclawClient.serviceApps.listServiceActions({
248
261
  bridgeSessionToken: session.token,
249
262
  });
250
- return actions.actions.find(
263
+ const target = actionList.actions.find(
251
264
  (action: ServiceActionListView['actions'][number]) => action.id === actionId,
252
265
  );
266
+ if (target && target.grantState !== 'not-granted') {
267
+ return [target];
268
+ }
269
+ const ungrantedActions = actionList.actions.filter((action) =>
270
+ action.grantState === 'not-granted'
271
+ );
272
+ const orderedActions = [
273
+ ...ungrantedActions.filter((action) => action.id === actionId),
274
+ ...ungrantedActions.filter((action) => action.id !== actionId),
275
+ ];
276
+ if (orderedActions.length > 0) {
277
+ return orderedActions;
278
+ }
279
+ return [{
280
+ appId: target?.appId ?? actionId.split('.')[0] ?? actionId,
281
+ description: target?.description,
282
+ id: actionId,
283
+ name: target?.name ?? actionId,
284
+ risk: target?.risk ?? 'dangerous',
285
+ title: target?.title,
286
+ }];
253
287
  };
254
288
 
255
289
  private createInputPreview = (input: Record<string, unknown> | undefined): string | undefined => {
@@ -264,29 +298,11 @@ export class PanelAppBridgeManager {
264
298
  }
265
299
  };
266
300
 
267
- private getSession = async (
268
- params: DocBrowserIframeMessageParams,
269
- ): Promise<PanelAppBridgeSessionView> => {
270
- const panelAppId = this.readPanelAppId(params.tab.currentUrl);
271
- const key = `${params.iframeInstanceId}:${panelAppId}`;
272
- let session = this.sessions.get(key);
273
- if (!session) {
274
- session = nextclawClient.panelApps.createBridgeSession({
275
- panelAppId,
276
- tabId: params.tab.id,
277
- });
278
- this.sessions.set(key, session);
279
- }
280
- return await session;
281
- };
282
-
283
- private readPanelAppId = (url: string): string => {
284
- const parsed = new URL(url, window.location.origin);
285
- const match = /^\/api\/panel-apps\/([^/]+)\/content$/.exec(parsed.pathname);
286
- if (!match?.[1]) {
287
- throw new Error('Current tab is not a Panel App.');
288
- }
289
- return decodeURIComponent(match[1]);
301
+ private getBridgeContext = (request: PanelAppBridgeRequest): PanelAppBridgeContext => {
302
+ return {
303
+ appId: request.appId,
304
+ token: request.runtimeToken,
305
+ };
290
306
  };
291
307
 
292
308
  private requireActionId = (request: PanelAppBridgeRequest): string => {
@@ -353,6 +369,8 @@ export class PanelAppBridgeManager {
353
369
  return (
354
370
  candidate.type === 'nextclaw:panel-app-service-actions:request' &&
355
371
  typeof candidate.requestId === 'string' &&
372
+ typeof candidate.runtimeToken === 'string' &&
373
+ typeof candidate.appId === 'string' &&
356
374
  (candidate.method === 'invoke' ||
357
375
  candidate.method === 'agent.send' ||
358
376
  candidate.method === 'agent.generateObject' ||
@@ -7,13 +7,17 @@ function createPanelAppEntry(overrides: Partial<PanelAppEntryView> = {}): PanelA
7
7
  const updatedAt = overrides.updatedAt ?? createdAt;
8
8
  return {
9
9
  id: overrides.id ?? overrides.fileName ?? 'demo.panel.html',
10
+ appId: overrides.appId ?? 'demo',
10
11
  fileName: overrides.fileName ?? 'demo.panel.html',
12
+ kind: overrides.kind ?? 'single-file',
11
13
  title: overrides.title ?? 'Demo',
12
14
  contentPath: overrides.contentPath ?? '/api/panel-apps/demo/content',
13
15
  createdAt,
14
16
  updatedAt,
15
17
  sizeBytes: overrides.sizeBytes ?? 12,
16
18
  favorite: overrides.favorite ?? false,
19
+ clientDeclared: overrides.clientDeclared ?? false,
20
+ clientGranted: overrides.clientGranted ?? false,
17
21
  openCount: overrides.openCount ?? 0,
18
22
  ...overrides,
19
23
  };