@nextclaw/ui 0.12.35-beta.3 → 0.12.35-beta.5

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 (116) hide show
  1. package/CHANGELOG.md +126 -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-DOKLoiqI.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-BFmG4_F7.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-renderer.types.ts +7 -0
  72. package/src/shared/components/doc-browser/doc-browser.tsx +16 -1
  73. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +272 -0
  74. package/src/shared/components/doc-browser/stores/doc-browser.store.ts +140 -0
  75. package/src/shared/components/doc-browser/types/doc-browser.types.ts +47 -0
  76. package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +47 -0
  77. package/src/shared/components/doc-browser/utils/doc-browser-url.utils.ts +95 -0
  78. package/src/shared/lib/api/managers/client.manager.ts +2 -1
  79. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +31 -0
  80. package/src/shared/lib/transport/local-transport.service.ts +18 -2
  81. package/src/shared/lib/transport/remote-transport.service.ts +19 -3
  82. package/src/shared/lib/transport/request-raw-api-response.utils.ts +1 -1
  83. package/dist/assets/api-D3uoaOR8.js +0 -15
  84. package/dist/assets/channels-list-page-DHmz9iHg.js +0 -8
  85. package/dist/assets/config-split-page-Dutq3R5o.js +0 -1
  86. package/dist/assets/createLucideIcon-Cwr2o6Zq.js +0 -1
  87. package/dist/assets/desktop-update-config-ClbDsIOs.js +0 -1
  88. package/dist/assets/dist-e5WATjIW.js +0 -1
  89. package/dist/assets/doc-browser-ChY6hMn8.js +0 -1
  90. package/dist/assets/doc-browser-cR1vWRs1.js +0 -1
  91. package/dist/assets/doc-browser-context-CLczyX2Z.js +0 -1
  92. package/dist/assets/es2015-BJw-hrex.js +0 -41
  93. package/dist/assets/i18n-tWKGEGxB.js +0 -1
  94. package/dist/assets/index-DPz9DRZN.js +0 -103
  95. package/dist/assets/index-HvJDzfSU.css +0 -1
  96. package/dist/assets/key-round-B8tTb3ZJ.js +0 -1
  97. package/dist/assets/mcp-marketplace-page-BYPxLdUR.js +0 -40
  98. package/dist/assets/mcp-marketplace-page-DXCvk7wJ.js +0 -1
  99. package/dist/assets/model-config-DcnC_fSr.js +0 -1
  100. package/dist/assets/notice-card-DUDbOvqx.js +0 -5
  101. package/dist/assets/play-CV_kwi9X.js +0 -1
  102. package/dist/assets/plus-BqfNq7Tu.js +0 -1
  103. package/dist/assets/provider-scoped-model-input-eO4XY0iY.js +0 -1
  104. package/dist/assets/providers-list-BgvjRVmp.js +0 -1
  105. package/dist/assets/remote-BCuZ1JJe.js +0 -1
  106. package/dist/assets/rotate-cw-BKEhWuWp.js +0 -1
  107. package/dist/assets/runtime-config-page-B2XPXHrk.js +0 -1
  108. package/dist/assets/secrets-config-4uY5sk-V.js +0 -3
  109. package/dist/assets/select-CmmDgG8o.js +0 -1
  110. package/dist/assets/setting-row-BUO6FeN1.js +0 -1
  111. package/dist/assets/tabs-custom-D3IZL9WX.js +0 -1
  112. package/dist/assets/tag-chip-Dm_pOtE3.js +0 -1
  113. package/dist/assets/x-DBP3WiC9.js +0 -1
  114. package/src/app/components/app-manager-provider.tsx +0 -20
  115. package/src/app/managers/app.manager.ts +0 -12
  116. /package/dist/assets/{config-hints-DGfwbumO.js → config-hints-CTqBnCDp.js} +0 -0
@@ -0,0 +1,290 @@
1
+ import { useState, type ReactNode } from 'react';
2
+ import type {
3
+ ServiceActionGrantView,
4
+ ServiceActionListView,
5
+ ServiceAppRecordView,
6
+ } from '@nextclaw/client-sdk';
7
+ import {
8
+ RefreshCw,
9
+ RotateCw,
10
+ Search,
11
+ Server,
12
+ ShieldCheck,
13
+ Trash2,
14
+ Wrench,
15
+ } from 'lucide-react';
16
+ import {
17
+ useDiscoverServiceAppActions,
18
+ useRestartServiceApp,
19
+ useRevokeServiceActionGrant,
20
+ useServiceActionGrants,
21
+ useServiceActions,
22
+ useServiceApps,
23
+ } from '@/features/service-apps/hooks/use-service-apps';
24
+ import { t } from '@/shared/lib/i18n';
25
+
26
+ type ServiceActionView = ServiceActionListView['actions'][number];
27
+
28
+ export function ServiceAppsPanel({
29
+ headerContent,
30
+ }: {
31
+ headerContent?: ReactNode;
32
+ }) {
33
+ const serviceApps = useServiceApps();
34
+ const serviceActions = useServiceActions();
35
+ const serviceActionGrants = useServiceActionGrants();
36
+ const restartServiceApp = useRestartServiceApp();
37
+ const discoverServiceAppActions = useDiscoverServiceAppActions();
38
+ const revokeServiceActionGrant = useRevokeServiceActionGrant();
39
+ const [discoveredActionsByApp, setDiscoveredActionsByApp] = useState<
40
+ Record<string, ServiceActionView[]>
41
+ >({});
42
+
43
+ const refetch = () => {
44
+ void serviceApps.refetch();
45
+ void serviceActions.refetch();
46
+ void serviceActionGrants.refetch();
47
+ };
48
+
49
+ if (serviceApps.isLoading || serviceActions.isLoading || serviceActionGrants.isLoading) {
50
+ return (
51
+ <div className="flex h-full items-center justify-center text-sm text-gray-500">
52
+ {t('serviceAppsLoading')}
53
+ </div>
54
+ );
55
+ }
56
+
57
+ if (serviceApps.isError || serviceActions.isError || serviceActionGrants.isError) {
58
+ const error = serviceApps.error ?? serviceActions.error ?? serviceActionGrants.error;
59
+ return (
60
+ <div className="p-4 text-sm text-rose-600">
61
+ {error instanceof Error ? error.message : t('serviceAppsLoadFailed')}
62
+ </div>
63
+ );
64
+ }
65
+
66
+ const apps = serviceApps.data?.entries ?? [];
67
+ const actions = serviceActions.data?.actions ?? [];
68
+ const grants = serviceActionGrants.data?.grants ?? [];
69
+ const discover = (appId: string) => {
70
+ void discoverServiceAppActions.mutateAsync(appId).then((result) => {
71
+ setDiscoveredActionsByApp((current) => ({
72
+ ...current,
73
+ [appId]: result.actions,
74
+ }));
75
+ });
76
+ };
77
+
78
+ return (
79
+ <div className="flex h-full min-h-0 flex-col bg-white">
80
+ <div className="flex items-center justify-between gap-2 border-b border-gray-100 px-4 py-3">
81
+ <div className="flex min-w-0 items-center gap-2">
82
+ {headerContent ?? (
83
+ <>
84
+ <Server className="h-4 w-4 text-primary" />
85
+ <div className="truncate text-sm font-semibold text-gray-900">{t('serviceAppsTitle')}</div>
86
+ </>
87
+ )}
88
+ </div>
89
+ <button
90
+ type="button"
91
+ onClick={refetch}
92
+ className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800"
93
+ title={t('serviceAppsRefresh')}
94
+ aria-label={t('serviceAppsRefresh')}
95
+ >
96
+ <RefreshCw className="h-3.5 w-3.5" />
97
+ </button>
98
+ </div>
99
+
100
+ {apps.length === 0 ? (
101
+ <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-gray-500">
102
+ {t('serviceAppsEmpty')}
103
+ </div>
104
+ ) : (
105
+ <div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
106
+ <div className="space-y-3">
107
+ {apps.map((app) => (
108
+ <ServiceAppCard
109
+ key={app.id}
110
+ app={app}
111
+ actions={discoveredActionsByApp[app.id] ?? actions.filter((action) => action.appId === app.id)}
112
+ grants={grants}
113
+ isDiscovering={discoverServiceAppActions.isPending}
114
+ onDiscover={discover}
115
+ onRestart={(appId) => void restartServiceApp.mutate(appId)}
116
+ onRevoke={(grant) => void revokeServiceActionGrant.mutate({
117
+ actionId: grant.actionId,
118
+ caller: grant.caller,
119
+ })}
120
+ />
121
+ ))}
122
+ </div>
123
+ </div>
124
+ )}
125
+ </div>
126
+ );
127
+ }
128
+
129
+ function ServiceAppCard({
130
+ app,
131
+ actions,
132
+ grants,
133
+ isDiscovering,
134
+ onDiscover,
135
+ onRestart,
136
+ onRevoke,
137
+ }: {
138
+ app: ServiceAppRecordView;
139
+ actions: ServiceActionView[];
140
+ grants: ServiceActionGrantView[];
141
+ isDiscovering: boolean;
142
+ onDiscover: (appId: string) => void;
143
+ onRestart: (appId: string) => void;
144
+ onRevoke: (grant: ServiceActionGrantView) => void;
145
+ }) {
146
+ return (
147
+ <section className="rounded-lg border border-gray-200 bg-white p-3">
148
+ <div className="flex items-start justify-between gap-3">
149
+ <div className="min-w-0">
150
+ <div className="flex items-center gap-2">
151
+ <div className="truncate text-sm font-semibold text-gray-900">{app.title}</div>
152
+ <span className="rounded bg-gray-100 px-1.5 py-0.5 text-[11px] text-gray-600">{app.status}</span>
153
+ </div>
154
+ {app.description ? (
155
+ <div className="mt-1 line-clamp-2 text-xs text-gray-500">{app.description}</div>
156
+ ) : null}
157
+ {app.lastError ? (
158
+ <div className="mt-2 rounded bg-rose-50 px-2 py-1 text-xs text-rose-700">{app.lastError}</div>
159
+ ) : null}
160
+ </div>
161
+ <div className="flex shrink-0 items-center gap-1">
162
+ <button
163
+ type="button"
164
+ onClick={() => onDiscover(app.id)}
165
+ disabled={isDiscovering}
166
+ className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50"
167
+ title={t('serviceAppsDiscoverActions')}
168
+ aria-label={t('serviceAppsDiscoverActions')}
169
+ >
170
+ <Search className="h-3.5 w-3.5" />
171
+ </button>
172
+ <button
173
+ type="button"
174
+ onClick={() => onRestart(app.id)}
175
+ className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800"
176
+ title={t('serviceAppsRestart')}
177
+ aria-label={t('serviceAppsRestart')}
178
+ >
179
+ <RotateCw className="h-3.5 w-3.5" />
180
+ </button>
181
+ </div>
182
+ </div>
183
+ <ServiceAppDiagnostics app={app} />
184
+ <div className="mt-3 space-y-1">
185
+ {actions.map((action) => (
186
+ <ServiceActionRow
187
+ key={action.id}
188
+ action={action}
189
+ grants={grants.filter((grant) => grant.actionId === action.id)}
190
+ onRevoke={onRevoke}
191
+ />
192
+ ))}
193
+ </div>
194
+ </section>
195
+ );
196
+ }
197
+
198
+ function ServiceAppDiagnostics({ app }: { app: ServiceAppRecordView }) {
199
+ const command = [app.command, ...(app.args ?? [])]
200
+ .filter((value): value is string => Boolean(value?.trim()))
201
+ .join(' ');
202
+ const rows = [
203
+ { label: t('serviceAppsCommand'), value: command },
204
+ { label: t('serviceAppsCwd'), value: app.cwd },
205
+ { label: t('serviceAppsManifest'), value: app.manifestPath },
206
+ { label: t('serviceAppsLastStarted'), value: app.lastStartedAt },
207
+ { label: t('serviceAppsLastReady'), value: app.lastReadyAt },
208
+ { label: t('serviceAppsLastFailed'), value: app.lastFailedAt },
209
+ ].filter((row) => row.value);
210
+
211
+ if (rows.length === 0) {
212
+ return null;
213
+ }
214
+
215
+ return (
216
+ <dl className="mt-3 grid grid-cols-[4.5rem_minmax(0,1fr)] gap-x-2 gap-y-1 border-t border-gray-100 pt-2 text-[11px]">
217
+ {rows.map((row) => (
218
+ <ServiceAppDiagnosticRow key={row.label} label={row.label} value={row.value} />
219
+ ))}
220
+ </dl>
221
+ );
222
+ }
223
+
224
+ function ServiceAppDiagnosticRow({
225
+ label,
226
+ value,
227
+ }: {
228
+ label: string;
229
+ value: string | undefined;
230
+ }) {
231
+ return (
232
+ <>
233
+ <dt className="text-gray-400">{label}</dt>
234
+ <dd className="min-w-0 truncate font-mono text-gray-500" title={value}>
235
+ {value}
236
+ </dd>
237
+ </>
238
+ );
239
+ }
240
+
241
+ function ServiceActionRow({
242
+ action,
243
+ grants,
244
+ onRevoke,
245
+ }: {
246
+ action: ServiceActionView;
247
+ grants: ServiceActionGrantView[];
248
+ onRevoke: (grant: ServiceActionGrantView) => void;
249
+ }) {
250
+ return (
251
+ <div className="rounded bg-gray-50 px-2 py-1.5">
252
+ <div className="flex items-center justify-between gap-2">
253
+ <div className="flex min-w-0 items-center gap-2">
254
+ <Wrench className="h-3.5 w-3.5 shrink-0 text-gray-400" />
255
+ <div className="min-w-0">
256
+ <div className="truncate text-xs font-medium text-gray-800">{action.title ?? action.name}</div>
257
+ {action.description ? (
258
+ <div className="truncate text-[11px] text-gray-500">{action.description}</div>
259
+ ) : null}
260
+ </div>
261
+ </div>
262
+ <div className="flex shrink-0 items-center gap-1">
263
+ {action.runtimeState ? (
264
+ <span className="rounded bg-white px-1.5 py-0.5 text-[11px] text-gray-600">
265
+ {t(`serviceAppsRuntimeState_${action.runtimeState}`)}
266
+ </span>
267
+ ) : null}
268
+ <span className="rounded bg-white px-1.5 py-0.5 text-[11px] text-gray-600">{action.risk}</span>
269
+ </div>
270
+ </div>
271
+ {grants.map((grant) => (
272
+ <div key={`${grant.caller.surface}:${grant.caller.appId}:${grant.actionId}`} className="mt-1 flex items-center justify-between gap-2 pl-5">
273
+ <div className="flex min-w-0 items-center gap-1.5 text-[11px] text-gray-500">
274
+ <ShieldCheck className="h-3 w-3 shrink-0 text-emerald-500" />
275
+ <span className="truncate">{t('serviceAppsGrantedTo')} {grant.caller.appId}</span>
276
+ </div>
277
+ <button
278
+ type="button"
279
+ onClick={() => onRevoke(grant)}
280
+ className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white hover:text-rose-600"
281
+ title={t('serviceAppsRevokeGrant')}
282
+ aria-label={t('serviceAppsRevokeGrant')}
283
+ >
284
+ <Trash2 className="h-3 w-3" />
285
+ </button>
286
+ </div>
287
+ ))}
288
+ </div>
289
+ );
290
+ }
@@ -0,0 +1,66 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { nextclawClient } from '@/shared/lib/api';
3
+
4
+ const SERVICE_APPS_QUERY_KEY = ['service-apps'] as const;
5
+ const SERVICE_ACTIONS_QUERY_KEY = ['service-actions'] as const;
6
+ const SERVICE_ACTION_GRANTS_QUERY_KEY = ['service-action-grants'] as const;
7
+
8
+ export function useServiceApps() {
9
+ return useQuery({
10
+ queryKey: SERVICE_APPS_QUERY_KEY,
11
+ queryFn: () => nextclawClient.serviceApps.listServiceApps(),
12
+ });
13
+ }
14
+
15
+ export function useServiceActions() {
16
+ return useQuery({
17
+ queryKey: SERVICE_ACTIONS_QUERY_KEY,
18
+ queryFn: () => nextclawClient.serviceApps.listServiceActions(),
19
+ });
20
+ }
21
+
22
+ export function useServiceActionGrants() {
23
+ return useQuery({
24
+ queryKey: SERVICE_ACTION_GRANTS_QUERY_KEY,
25
+ queryFn: () => nextclawClient.serviceApps.listServiceActionGrants(),
26
+ });
27
+ }
28
+
29
+ export function useRestartServiceApp() {
30
+ const queryClient = useQueryClient();
31
+ return useMutation({
32
+ mutationFn: (appId: string) => nextclawClient.serviceApps.restartServiceApp(appId),
33
+ onSuccess: async () => {
34
+ await Promise.all([
35
+ queryClient.invalidateQueries({ queryKey: SERVICE_APPS_QUERY_KEY }),
36
+ queryClient.invalidateQueries({ queryKey: SERVICE_ACTIONS_QUERY_KEY }),
37
+ ]);
38
+ },
39
+ });
40
+ }
41
+
42
+ export function useDiscoverServiceAppActions() {
43
+ const queryClient = useQueryClient();
44
+ return useMutation({
45
+ mutationFn: (appId: string) => nextclawClient.serviceApps.discoverServiceAppActions(appId),
46
+ onSuccess: async () => {
47
+ await queryClient.invalidateQueries({ queryKey: SERVICE_APPS_QUERY_KEY });
48
+ },
49
+ });
50
+ }
51
+
52
+ export function useRevokeServiceActionGrant() {
53
+ const queryClient = useQueryClient();
54
+ return useMutation({
55
+ mutationFn: (params: {
56
+ actionId: string;
57
+ caller: { surface: 'panel-app'; appId: string };
58
+ }) => nextclawClient.serviceApps.revokeServiceActionGrant(params),
59
+ onSuccess: async () => {
60
+ await Promise.all([
61
+ queryClient.invalidateQueries({ queryKey: SERVICE_ACTIONS_QUERY_KEY }),
62
+ queryClient.invalidateQueries({ queryKey: SERVICE_ACTION_GRANTS_QUERY_KEY }),
63
+ ]);
64
+ },
65
+ });
66
+ }
@@ -0,0 +1,3 @@
1
+ export { ServiceActionAuthorizationDialog } from '@/features/service-apps/components/service-action-authorization-dialog';
2
+ export { ServiceActionAuthorizationManager } from '@/features/service-apps/managers/service-action-authorization.manager';
3
+ export { ServiceAppsPanel } from '@/features/service-apps/components/service-apps-panel';
@@ -0,0 +1,10 @@
1
+ import { useServiceActionAuthorizationStore } from '@/features/service-apps/stores/service-action-authorization.store';
2
+ import type { ServiceActionAuthorizationRequest } from '@/features/service-apps/stores/service-action-authorization.store';
3
+
4
+ export class ServiceActionAuthorizationManager {
5
+ requestAuthorization = async (
6
+ request: Omit<ServiceActionAuthorizationRequest, 'id'>,
7
+ ): Promise<boolean> => {
8
+ return await useServiceActionAuthorizationStore.getState().requestAuthorization(request);
9
+ };
10
+ }
@@ -0,0 +1,53 @@
1
+ import { create } from 'zustand';
2
+ import type { ServiceActionListView } from '@nextclaw/client-sdk';
3
+
4
+ type ServiceActionRisk = ServiceActionListView['actions'][number]['risk'];
5
+
6
+ export type ServiceActionAuthorizationRequest = {
7
+ id: string;
8
+ panelAppId: string;
9
+ actionId: string;
10
+ actionTitle?: string;
11
+ actionDescription?: string;
12
+ risk?: ServiceActionRisk;
13
+ inputPreview?: string;
14
+ };
15
+
16
+ type PendingAuthorization = ServiceActionAuthorizationRequest & {
17
+ resolve: (allowed: boolean) => void;
18
+ };
19
+
20
+ type ServiceActionAuthorizationState = {
21
+ pending: PendingAuthorization | null;
22
+ requestAuthorization: (
23
+ request: Omit<ServiceActionAuthorizationRequest, 'id'>,
24
+ ) => Promise<boolean>;
25
+ resolveAuthorization: (allowed: boolean) => void;
26
+ };
27
+
28
+ function createAuthorizationId(): string {
29
+ return `service-action-auth-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
30
+ }
31
+
32
+ export const useServiceActionAuthorizationStore = create<ServiceActionAuthorizationState>((set, get) => ({
33
+ pending: null,
34
+ requestAuthorization: async (request) =>
35
+ await new Promise<boolean>((resolve) => {
36
+ get().pending?.resolve(false);
37
+ set({
38
+ pending: {
39
+ id: createAuthorizationId(),
40
+ ...request,
41
+ resolve,
42
+ },
43
+ });
44
+ }),
45
+ resolveAuthorization: (allowed) => {
46
+ const { pending } = get();
47
+ if (!pending) {
48
+ return;
49
+ }
50
+ pending.resolve(allowed);
51
+ set({ pending: null });
52
+ },
53
+ }));
@@ -1,6 +1,8 @@
1
- import { act, renderHook } from '@testing-library/react';
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
2
  import type { ReactNode } from 'react';
3
3
  import { DocBrowserProvider, useDocBrowser } from './doc-browser-context';
4
+ import { createDefaultDocBrowserState } from '@/shared/components/doc-browser/utils/doc-browser-state.utils';
5
+ import { useDocBrowserStore } from '@/shared/components/doc-browser/stores/doc-browser.store';
4
6
 
5
7
  vi.mock('@/shared/lib/i18n', async () => {
6
8
  const actual = await vi.importActual('@/shared/lib/i18n');
@@ -14,6 +16,39 @@ const wrapper = ({ children }: { children: ReactNode }) => (
14
16
  <DocBrowserProvider>{children}</DocBrowserProvider>
15
17
  );
16
18
 
19
+ const docBrowserStorageKey = 'nextclaw.doc-browser.state';
20
+
21
+ beforeEach(() => {
22
+ act(() => {
23
+ useDocBrowserStore.setState({ snapshot: createDefaultDocBrowserState() });
24
+ });
25
+ window.localStorage.clear();
26
+ });
27
+
28
+ function readPersistedDocBrowserSnapshot() {
29
+ return JSON.parse(window.localStorage.getItem(docBrowserStorageKey) ?? '{}') as {
30
+ state?: {
31
+ snapshot?: Record<string, unknown>;
32
+ };
33
+ };
34
+ }
35
+
36
+ async function rehydrateDocBrowserFromStorage() {
37
+ await act(async () => {
38
+ await useDocBrowserStore.persist.rehydrate();
39
+ });
40
+ }
41
+
42
+ async function resetMemoryThenRehydrate(savedState: string | null) {
43
+ act(() => {
44
+ useDocBrowserStore.setState({ snapshot: createDefaultDocBrowserState() });
45
+ });
46
+ if (savedState) {
47
+ window.localStorage.setItem(docBrowserStorageKey, savedState);
48
+ }
49
+ await rehydrateDocBrowserFromStorage();
50
+ }
51
+
17
52
  describe('DocBrowserProvider dedupe keys', () => {
18
53
  it('opens different dedupe keys as separate content tabs', () => {
19
54
  const { result } = renderHook(() => useDocBrowser(), { wrapper });
@@ -99,4 +134,118 @@ describe('DocBrowserProvider dedupe keys', () => {
99
134
  currentUrl: 'data:text/html,A-loaded',
100
135
  });
101
136
  });
137
+
138
+ it('updates custom URL query state for the same dedupe key', () => {
139
+ const { result } = renderHook(() => useDocBrowser(), { wrapper });
140
+
141
+ act(() => {
142
+ result.current.open('nextclaw://apps', {
143
+ dedupeKey: 'apps',
144
+ kind: 'apps',
145
+ title: 'Apps',
146
+ });
147
+ });
148
+ act(() => {
149
+ result.current.open('nextclaw://apps?tab=service-apps', {
150
+ activate: false,
151
+ dedupeKey: 'apps',
152
+ kind: 'apps',
153
+ title: 'Apps',
154
+ });
155
+ });
156
+
157
+ expect(result.current.currentTab).toMatchObject({
158
+ currentUrl: 'nextclaw://apps?tab=service-apps',
159
+ dedupeKey: 'apps',
160
+ kind: 'apps',
161
+ });
162
+ });
163
+ });
164
+
165
+ describe('DocBrowserProvider persistence', () => {
166
+ it('restores the open panel and active tab from the persisted Zustand store', async () => {
167
+ const { result } = renderHook(() => useDocBrowser(), { wrapper });
168
+
169
+ act(() => {
170
+ result.current.open('nextclaw://apps?tab=service-apps', {
171
+ dedupeKey: 'apps',
172
+ kind: 'apps',
173
+ title: 'Apps',
174
+ });
175
+ });
176
+
177
+ await waitFor(() => {
178
+ expect(readPersistedDocBrowserSnapshot()).toMatchObject({
179
+ state: {
180
+ snapshot: {
181
+ isOpen: true,
182
+ activeTabId: result.current.activeTabId,
183
+ },
184
+ },
185
+ });
186
+ });
187
+
188
+ const savedState = window.localStorage.getItem(docBrowserStorageKey);
189
+ await resetMemoryThenRehydrate(savedState);
190
+ const restored = renderHook(() => useDocBrowser(), { wrapper });
191
+
192
+ expect(restored.result.current.isOpen).toBe(true);
193
+ expect(restored.result.current.currentTab).toMatchObject({
194
+ kind: 'apps',
195
+ currentUrl: 'nextclaw://apps?tab=service-apps',
196
+ dedupeKey: 'apps',
197
+ });
198
+ });
199
+
200
+ it('keeps the panel closed after a closed state is persisted', async () => {
201
+ const { result } = renderHook(() => useDocBrowser(), { wrapper });
202
+
203
+ act(() => {
204
+ result.current.open('nextclaw://apps', {
205
+ dedupeKey: 'apps',
206
+ kind: 'apps',
207
+ title: 'Apps',
208
+ });
209
+ result.current.close();
210
+ });
211
+
212
+ await waitFor(() => {
213
+ expect(readPersistedDocBrowserSnapshot()).toMatchObject({
214
+ state: {
215
+ snapshot: {
216
+ isOpen: false,
217
+ },
218
+ },
219
+ });
220
+ });
221
+
222
+ const savedState = window.localStorage.getItem(docBrowserStorageKey);
223
+ await resetMemoryThenRehydrate(savedState);
224
+ const restored = renderHook(() => useDocBrowser(), { wrapper });
225
+
226
+ expect(restored.result.current.isOpen).toBe(false);
227
+ expect(restored.result.current.currentTab).toMatchObject({
228
+ kind: 'apps',
229
+ currentUrl: 'nextclaw://apps',
230
+ });
231
+ });
232
+
233
+ it('falls back to the default docs tab when persisted state is malformed', async () => {
234
+ window.localStorage.setItem(docBrowserStorageKey, JSON.stringify({
235
+ version: 1,
236
+ state: {
237
+ snapshot: {
238
+ isOpen: true,
239
+ tabs: [{ id: 'broken' }],
240
+ activeTabId: 'broken',
241
+ },
242
+ },
243
+ }));
244
+
245
+ await rehydrateDocBrowserFromStorage();
246
+ const { result } = renderHook(() => useDocBrowser(), { wrapper });
247
+
248
+ expect(result.current.isOpen).toBe(false);
249
+ expect(result.current.currentTab?.kind).toBe('docs');
250
+ });
102
251
  });