@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,128 @@
|
|
|
1
|
+
// Renders the row of UI Action chips beneath an assistant bubble.
|
|
2
|
+
// Payload comes from `message.actions[]` produced by the polisher and
|
|
3
|
+
// validated by Core's `cp.uiActions_api`.
|
|
4
|
+
//
|
|
5
|
+
// Navigation is host-owned: chips targeting a registered surface mount it
|
|
6
|
+
// directly via the surface store; anything else goes through the navigator
|
|
7
|
+
// registered with setUIActionNavigator() (no router imports here).
|
|
8
|
+
|
|
9
|
+
import { View, Text, Pressable, StyleSheet, Platform } from 'react-native';
|
|
10
|
+
import { ada, fontSize } from '@ateam-ai-mobile/core';
|
|
11
|
+
import type { UIAction } from '@ateam-ai-mobile/core';
|
|
12
|
+
import { useSurfaceStore, getHaptics } from '@ateam-ai-mobile/plugin-runtime';
|
|
13
|
+
import { getUIActionNavigator } from '../providers';
|
|
14
|
+
|
|
15
|
+
const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
|
|
16
|
+
|
|
17
|
+
const ICON_MAP: Record<string, string> = {
|
|
18
|
+
timer: '⏱',
|
|
19
|
+
memory: '🧠',
|
|
20
|
+
calendar: '📅',
|
|
21
|
+
home: '🏠',
|
|
22
|
+
bell: '🔔',
|
|
23
|
+
star: '⭐',
|
|
24
|
+
bolt: '⚡',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
actions: UIAction[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function UIActionsRow({ actions }: Props) {
|
|
32
|
+
if (!Array.isArray(actions) || actions.length === 0) return null;
|
|
33
|
+
|
|
34
|
+
const handlePress = (a: UIAction) => {
|
|
35
|
+
getHaptics()?.selection();
|
|
36
|
+
// Validator returns pluginId as the full id ("mcp:connector:plugin" for
|
|
37
|
+
// solution plugins, "platform:<name>" for host platform screens). Fall
|
|
38
|
+
// back to target (short id) only for debugging.
|
|
39
|
+
const id = a.pluginId || a.target;
|
|
40
|
+
if (!id) return;
|
|
41
|
+
|
|
42
|
+
// Compute focus once; both branches use it.
|
|
43
|
+
const focus = a.payload?.id || a.payload?.entity_id || a.payload?.focusId;
|
|
44
|
+
|
|
45
|
+
// Host-rendered platform surfaces live in the surface store, not in the
|
|
46
|
+
// dynamic plugin registry. Mount the registered drawer/fullscreen
|
|
47
|
+
// directly — skip navigation (which would try getPlugin() and fail).
|
|
48
|
+
const surface = useSurfaceStore.getState().manifests[id];
|
|
49
|
+
if (surface) {
|
|
50
|
+
useSurfaceStore.getState().show(id, focus ? { focus } : undefined);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Pass payload fields as params so the plugin can focus the relevant
|
|
55
|
+
// entity on load (e.g. ?focus=rem_abc).
|
|
56
|
+
const params: Record<string, string> = {};
|
|
57
|
+
if (focus) params.focus = String(focus);
|
|
58
|
+
if (a.intent === 'suggested_action' && a.payload?.tool) {
|
|
59
|
+
params.tool = String(a.payload.tool);
|
|
60
|
+
if (a.payload?.args) {
|
|
61
|
+
try { params.args = JSON.stringify(a.payload.args); } catch {}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (a.intent === 'quick_toggle' && a.payload?.verb) {
|
|
65
|
+
params.verb = String(a.payload.verb);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const navigate = getUIActionNavigator();
|
|
69
|
+
if (!navigate) {
|
|
70
|
+
console.warn('[UIActionsRow] no navigator registered — call setUIActionNavigator() in the host to handle plugin chips');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
navigate({ pluginId: id, params });
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<View style={styles.row}>
|
|
78
|
+
{actions.map((a, i) => {
|
|
79
|
+
const icon = a.icon ? ICON_MAP[a.icon.toLowerCase()] : null;
|
|
80
|
+
return (
|
|
81
|
+
<Pressable
|
|
82
|
+
key={`${a.target || 'a'}-${i}`}
|
|
83
|
+
accessibilityRole="button"
|
|
84
|
+
accessibilityLabel={a.description || a.label}
|
|
85
|
+
onPress={() => handlePress(a)}
|
|
86
|
+
style={({ pressed }) => [styles.chip, pressed && styles.chipPressed]}
|
|
87
|
+
>
|
|
88
|
+
{icon ? <Text style={styles.icon}>{icon}</Text> : null}
|
|
89
|
+
<Text style={styles.label} numberOfLines={1}>{a.label}</Text>
|
|
90
|
+
</Pressable>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
</View>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const styles = StyleSheet.create({
|
|
98
|
+
row: {
|
|
99
|
+
flexDirection: 'row',
|
|
100
|
+
flexWrap: 'wrap',
|
|
101
|
+
gap: 6,
|
|
102
|
+
marginTop: 10,
|
|
103
|
+
},
|
|
104
|
+
chip: {
|
|
105
|
+
flexDirection: 'row',
|
|
106
|
+
alignItems: 'center',
|
|
107
|
+
gap: 6,
|
|
108
|
+
paddingHorizontal: 12,
|
|
109
|
+
paddingVertical: 6,
|
|
110
|
+
backgroundColor: ada.orangeSoft,
|
|
111
|
+
borderColor: ada.orange,
|
|
112
|
+
borderWidth: 1,
|
|
113
|
+
borderRadius: 14,
|
|
114
|
+
},
|
|
115
|
+
chipPressed: {
|
|
116
|
+
backgroundColor: ada.orange,
|
|
117
|
+
},
|
|
118
|
+
icon: {
|
|
119
|
+
fontSize: 12,
|
|
120
|
+
lineHeight: 14,
|
|
121
|
+
},
|
|
122
|
+
label: {
|
|
123
|
+
color: ada.orangeDeep,
|
|
124
|
+
fontSize: fontSize.xs,
|
|
125
|
+
fontWeight: '600',
|
|
126
|
+
fontFamily: fontFam,
|
|
127
|
+
},
|
|
128
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ateam-ai-mobile/ui — prebuilt ADAS experience for React Native hosts.
|
|
3
|
+
*
|
|
4
|
+
* Built ONLY on public @ateam-ai-mobile/core + @ateam-ai-mobile/plugin-runtime APIs. Drop in
|
|
5
|
+
* <AdasChatScreen /> for the full chat, or compose the pieces yourself.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// The drop-in experience
|
|
9
|
+
export { default as AdasChatScreen } from './AdasChatScreen';
|
|
10
|
+
export type { AdasChatScreenProps } from './AdasChatScreen';
|
|
11
|
+
|
|
12
|
+
// Chat pieces (for custom layouts)
|
|
13
|
+
export { default as MessageBubble, DaySeparator } from './chat/MessageBubble';
|
|
14
|
+
export { default as ChatInput } from './chat/ChatInput';
|
|
15
|
+
export { default as TypingIndicator } from './chat/TypingIndicator';
|
|
16
|
+
export { default as JobProgressBar } from './chat/JobProgressBar';
|
|
17
|
+
export { default as TableCardBlock } from './chat/TableCardBlock';
|
|
18
|
+
export { default as UIActionsRow } from './chat/UIActionsRow';
|
|
19
|
+
|
|
20
|
+
// Plugin rendering (native bundles + iframe fallback + surface wrapper)
|
|
21
|
+
export { default as Plugin } from './plugin/Plugin';
|
|
22
|
+
export { default as PluginSurface } from './plugin/PluginSurface';
|
|
23
|
+
export { default as EmbeddedPluginWebView } from './plugin/EmbeddedPluginWebView';
|
|
24
|
+
export { usePluginBridge, buildPluginBridgeJS } from './plugin/usePluginBridge';
|
|
25
|
+
|
|
26
|
+
// Widgets
|
|
27
|
+
export { default as WidgetWebView } from './widgets/WidgetWebView';
|
|
28
|
+
|
|
29
|
+
// Image lightbox
|
|
30
|
+
export { default as Lightbox } from './Lightbox';
|
|
31
|
+
export { useLightboxStore } from './stores/lightboxStore';
|
|
32
|
+
|
|
33
|
+
// Host integration
|
|
34
|
+
export {
|
|
35
|
+
setClipboardProvider,
|
|
36
|
+
setUIActionNavigator,
|
|
37
|
+
} from './providers';
|
|
38
|
+
export type { ClipboardProvider, UIActionRoute } from './providers';
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useState, useMemo, useCallback } from 'react';
|
|
2
|
+
import { View, Text, ActivityIndicator, StyleSheet, Platform } from 'react-native';
|
|
3
|
+
import { WebView } from 'react-native-webview';
|
|
4
|
+
import { getConfig, getCoreApiUrl, getTenant, getToken, ada } from '@ateam-ai-mobile/core';
|
|
5
|
+
import { resolvePluginUrl, type PlatformPluginManifest as PluginManifest } from '@ateam-ai-mobile/plugin-runtime';
|
|
6
|
+
import { usePluginBridge, buildPluginBridgeJS } from './usePluginBridge';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
pluginId: string;
|
|
10
|
+
manifest: PluginManifest;
|
|
11
|
+
commandBufferRef?: React.MutableRefObject<any[]>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* WebView renderer for iframe-mode plugins. The injected bridge JS makes
|
|
16
|
+
* existing web plugins work unmodified; the origin guards below make sure
|
|
17
|
+
* the JWT seeded into localStorage can never leave the trusted hosts.
|
|
18
|
+
*/
|
|
19
|
+
export default function EmbeddedPluginWebView({ pluginId, manifest, commandBufferRef }: Props) {
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
const token = getToken();
|
|
23
|
+
const tenant = getTenant();
|
|
24
|
+
|
|
25
|
+
// _connectorId from the manifest (set by cp.getContextPlugin) — same as
|
|
26
|
+
// what the web PluginHost sends in init.
|
|
27
|
+
const { webViewRef, sendInit, handleMessage } = usePluginBridge(pluginId, manifest._connectorId, commandBufferRef);
|
|
28
|
+
|
|
29
|
+
const url = resolvePluginUrl(manifest, tenant || undefined);
|
|
30
|
+
const bridgeJS = buildPluginBridgeJS(token || '', tenant || '');
|
|
31
|
+
|
|
32
|
+
// Hosts the plugin may navigate to — derived from the SDK config so the
|
|
33
|
+
// whitelist always matches the backend the host pointed us at.
|
|
34
|
+
const allowedHosts = useMemo(() => {
|
|
35
|
+
const hosts = new Set<string>();
|
|
36
|
+
const urls = [getCoreApiUrl()];
|
|
37
|
+
const voice = getConfig().voiceUrl;
|
|
38
|
+
if (voice) urls.push(voice);
|
|
39
|
+
for (const u of urls) {
|
|
40
|
+
try { hosts.add(new URL(u).hostname); } catch { /* ignore bad URL */ }
|
|
41
|
+
}
|
|
42
|
+
return hosts;
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const isAllowedPluginUrl = useCallback((rawUrl: string | undefined | null): boolean => {
|
|
46
|
+
if (!rawUrl) return false;
|
|
47
|
+
// about:blank, data:, file: — never allow off-origin navigation
|
|
48
|
+
let parsed: URL;
|
|
49
|
+
try { parsed = new URL(rawUrl); } catch { return false; }
|
|
50
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false;
|
|
51
|
+
if (parsed.protocol === 'http:') return false; // belt-and-suspenders with mixedContentMode=never
|
|
52
|
+
return allowedHosts.has(parsed.hostname);
|
|
53
|
+
}, [allowedHosts]);
|
|
54
|
+
|
|
55
|
+
const originWhitelist = useMemo(
|
|
56
|
+
() => Array.from(allowedHosts).map(h => `https://${h}`),
|
|
57
|
+
[allowedHosts],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (error) {
|
|
61
|
+
return (
|
|
62
|
+
<View style={styles.center}>
|
|
63
|
+
<Text style={styles.errorText}>{error}</Text>
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<View style={styles.container}>
|
|
70
|
+
<WebView
|
|
71
|
+
ref={webViewRef}
|
|
72
|
+
source={{ uri: url }}
|
|
73
|
+
style={styles.webview}
|
|
74
|
+
javaScriptEnabled
|
|
75
|
+
domStorageEnabled
|
|
76
|
+
allowsInlineMediaPlayback
|
|
77
|
+
mediaPlaybackRequiresUserAction={false}
|
|
78
|
+
// SECURITY:
|
|
79
|
+
// mixedContentMode=never → block any plain HTTP load inside the WebView.
|
|
80
|
+
// originWhitelist → only the ADAS-owned hosts.
|
|
81
|
+
// onShouldStartLoadWithRequest → reject navigation off allowlisted hosts.
|
|
82
|
+
// setSupportMultipleWindows / javaScriptCanOpenWindowsAutomatically
|
|
83
|
+
// → prevent window.open() from spawning a WebView that inherits
|
|
84
|
+
// the bridge but skips the origin guard.
|
|
85
|
+
// allowsBackForwardNavigationGestures=false
|
|
86
|
+
// → a user gesture must not be a way out of the sandbox.
|
|
87
|
+
// The bridge writes the JWT into the WebView's localStorage; these
|
|
88
|
+
// guards make sure it can never leave the trusted origins.
|
|
89
|
+
mixedContentMode="never"
|
|
90
|
+
originWhitelist={originWhitelist}
|
|
91
|
+
allowsBackForwardNavigationGestures={false}
|
|
92
|
+
setSupportMultipleWindows={false}
|
|
93
|
+
javaScriptCanOpenWindowsAutomatically={false}
|
|
94
|
+
onShouldStartLoadWithRequest={(req) => {
|
|
95
|
+
if (!isAllowedPluginUrl(req.url)) {
|
|
96
|
+
console.warn(`[EmbeddedPluginWebView] BLOCKED nav to disallowed origin: ${req.url}`);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}}
|
|
101
|
+
injectedJavaScriptBeforeContentLoaded={bridgeJS}
|
|
102
|
+
onMessage={handleMessage}
|
|
103
|
+
onLoad={() => {
|
|
104
|
+
// Send init with retries to beat the race condition
|
|
105
|
+
setTimeout(sendInit, 100);
|
|
106
|
+
setTimeout(sendInit, 500);
|
|
107
|
+
setTimeout(sendInit, 1200);
|
|
108
|
+
}}
|
|
109
|
+
onError={(syntheticEvent) => {
|
|
110
|
+
const { nativeEvent } = syntheticEvent;
|
|
111
|
+
console.warn('[EmbeddedPluginWebView] Error:', nativeEvent.description);
|
|
112
|
+
setError(`WebView error: ${nativeEvent.description}`);
|
|
113
|
+
}}
|
|
114
|
+
onHttpError={(syntheticEvent) => {
|
|
115
|
+
const { nativeEvent } = syntheticEvent;
|
|
116
|
+
console.warn('[EmbeddedPluginWebView] HTTP error:', nativeEvent.statusCode, nativeEvent.url);
|
|
117
|
+
if (nativeEvent.statusCode >= 400) {
|
|
118
|
+
setError(`HTTP ${nativeEvent.statusCode}: Failed to load plugin`);
|
|
119
|
+
}
|
|
120
|
+
}}
|
|
121
|
+
startInLoadingState
|
|
122
|
+
renderLoading={() => (
|
|
123
|
+
<View style={styles.webviewLoading}>
|
|
124
|
+
<ActivityIndicator size="small" color={ada.orange} />
|
|
125
|
+
<Text style={styles.loadingText}>Loading plugin...</Text>
|
|
126
|
+
</View>
|
|
127
|
+
)}
|
|
128
|
+
{...(Platform.OS === 'ios' ? { bounces: false } : {})}
|
|
129
|
+
/>
|
|
130
|
+
</View>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const styles = StyleSheet.create({
|
|
135
|
+
container: {
|
|
136
|
+
flex: 1,
|
|
137
|
+
backgroundColor: ada.paper,
|
|
138
|
+
},
|
|
139
|
+
center: {
|
|
140
|
+
flex: 1,
|
|
141
|
+
justifyContent: 'center',
|
|
142
|
+
alignItems: 'center',
|
|
143
|
+
backgroundColor: ada.paper,
|
|
144
|
+
paddingHorizontal: 32,
|
|
145
|
+
},
|
|
146
|
+
errorText: {
|
|
147
|
+
color: '#C15545',
|
|
148
|
+
fontSize: 16,
|
|
149
|
+
textAlign: 'center',
|
|
150
|
+
},
|
|
151
|
+
webview: {
|
|
152
|
+
flex: 1,
|
|
153
|
+
backgroundColor: ada.paper,
|
|
154
|
+
},
|
|
155
|
+
webviewLoading: {
|
|
156
|
+
position: 'absolute',
|
|
157
|
+
top: 0,
|
|
158
|
+
left: 0,
|
|
159
|
+
right: 0,
|
|
160
|
+
bottom: 0,
|
|
161
|
+
justifyContent: 'center',
|
|
162
|
+
alignItems: 'center',
|
|
163
|
+
backgroundColor: ada.paper,
|
|
164
|
+
gap: 12,
|
|
165
|
+
},
|
|
166
|
+
loadingText: {
|
|
167
|
+
color: ada.inkSoft,
|
|
168
|
+
fontSize: 14,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <Plugin /> — unified plugin renderer.
|
|
3
|
+
*
|
|
4
|
+
* Auto-detects native vs webview, provides bridge/theme/context, wraps in an
|
|
5
|
+
* error boundary.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <Plugin id="mcp:connector:plugin-name" manifest={manifest} />
|
|
9
|
+
*/
|
|
10
|
+
import React, { useMemo } from 'react';
|
|
11
|
+
import { View, Text, Pressable, ActivityIndicator, StyleSheet } from 'react-native';
|
|
12
|
+
import { getTenant, getUser, colors } from '@ateam-ai-mobile/core';
|
|
13
|
+
import {
|
|
14
|
+
usePlugin,
|
|
15
|
+
useNativeBridge,
|
|
16
|
+
usePluginTheme,
|
|
17
|
+
getPluginNative,
|
|
18
|
+
type PluginContext,
|
|
19
|
+
} from '@ateam-ai-mobile/plugin-runtime';
|
|
20
|
+
import EmbeddedPluginWebView from './EmbeddedPluginWebView';
|
|
21
|
+
|
|
22
|
+
// ── Error Boundary ──────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
interface ErrorBoundaryState {
|
|
25
|
+
hasError: boolean;
|
|
26
|
+
error: Error | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class PluginErrorBoundary extends React.Component<
|
|
30
|
+
{ pluginId: string; children: React.ReactNode },
|
|
31
|
+
ErrorBoundaryState
|
|
32
|
+
> {
|
|
33
|
+
state: ErrorBoundaryState = { hasError: false, error: null };
|
|
34
|
+
|
|
35
|
+
static getDerivedStateFromError(error: Error) {
|
|
36
|
+
return { hasError: true, error };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
|
40
|
+
console.error(`[Plugin:${this.props.pluginId}] Crash:`, error, info.componentStack);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
render() {
|
|
44
|
+
if (this.state.hasError) {
|
|
45
|
+
return (
|
|
46
|
+
<View style={styles.error}>
|
|
47
|
+
<Text style={styles.errorTitle}>Plugin Error</Text>
|
|
48
|
+
<Text style={styles.errorMessage}>
|
|
49
|
+
{this.state.error?.message || 'An unexpected error occurred'}
|
|
50
|
+
</Text>
|
|
51
|
+
<Pressable
|
|
52
|
+
style={styles.retryBtn}
|
|
53
|
+
onPress={() => this.setState({ hasError: false, error: null })}
|
|
54
|
+
>
|
|
55
|
+
<Text style={styles.retryText}>Retry</Text>
|
|
56
|
+
</Pressable>
|
|
57
|
+
</View>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return this.props.children;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Plugin Component ─────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
interface PluginRendererProps {
|
|
67
|
+
/** Full plugin ID, e.g. "mcp:connector-id:plugin-name" */
|
|
68
|
+
id: string;
|
|
69
|
+
/** Plugin manifest (from API) */
|
|
70
|
+
manifest: any;
|
|
71
|
+
/** Shared command buffer from parent — captures commands before plugin mounts */
|
|
72
|
+
commandBufferRef?: React.MutableRefObject<any[]>;
|
|
73
|
+
/** Engine-supplied props from sys.focusUiPlugin(id, props) — forwarded to
|
|
74
|
+
* the plugin Component as `PluginProps.props`. */
|
|
75
|
+
props?: any;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default function Plugin({ id, manifest, commandBufferRef, props: engineProps }: PluginRendererProps) {
|
|
79
|
+
// Load the plugin component (handles cache, download, eval)
|
|
80
|
+
const { plugin, loading, error, bundleMissing } = usePlugin(id, manifest);
|
|
81
|
+
|
|
82
|
+
// Extract connectorId from MCP plugin ID: mcp:<connector>:<plugin>
|
|
83
|
+
const connectorId = id.startsWith('mcp:') ? id.split(':')[1] : manifest._connectorId;
|
|
84
|
+
|
|
85
|
+
// Wire up bridge, capabilities, theme
|
|
86
|
+
const bridge = useNativeBridge(id, connectorId, commandBufferRef);
|
|
87
|
+
const native = getPluginNative();
|
|
88
|
+
const theme = usePluginTheme();
|
|
89
|
+
|
|
90
|
+
// Build context from the host-owned identity
|
|
91
|
+
const user = getUser();
|
|
92
|
+
const context = useMemo<PluginContext>(() => ({
|
|
93
|
+
tenant: getTenant(),
|
|
94
|
+
pluginId: id,
|
|
95
|
+
connectorId,
|
|
96
|
+
user: user ? { id: user.actorId || '', name: user.name || '', roles: [] } : undefined,
|
|
97
|
+
}), [id, connectorId, user?.actorId, user?.name]);
|
|
98
|
+
|
|
99
|
+
// Fallback: connector declared an RN component but didn't ship the bundle.
|
|
100
|
+
// When the manifest also has an iframeUrl (adaptive mode), render the
|
|
101
|
+
// WebView instead of an error screen. Must run AFTER all hook calls above
|
|
102
|
+
// to keep hook order stable across renders.
|
|
103
|
+
const iframeUrl = manifest?.render?.iframeUrl;
|
|
104
|
+
if (bundleMissing && iframeUrl) {
|
|
105
|
+
return <EmbeddedPluginWebView pluginId={id} manifest={manifest} commandBufferRef={commandBufferRef} />;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (loading) {
|
|
109
|
+
return (
|
|
110
|
+
<View style={styles.center}>
|
|
111
|
+
<ActivityIndicator size="small" color={colors.dark.accent} />
|
|
112
|
+
<Text style={styles.loadingText}>Loading plugin...</Text>
|
|
113
|
+
</View>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (error || !plugin) {
|
|
118
|
+
return (
|
|
119
|
+
<View style={styles.center}>
|
|
120
|
+
<Text style={styles.errorMessage}>{error || 'Plugin not found'}</Text>
|
|
121
|
+
</View>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const { Component } = plugin;
|
|
126
|
+
return (
|
|
127
|
+
<PluginErrorBoundary pluginId={id}>
|
|
128
|
+
<Component bridge={bridge} native={native} theme={theme} context={context} props={engineProps} />
|
|
129
|
+
</PluginErrorBoundary>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Styles ───────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
const styles = StyleSheet.create({
|
|
136
|
+
center: {
|
|
137
|
+
flex: 1,
|
|
138
|
+
justifyContent: 'center',
|
|
139
|
+
alignItems: 'center',
|
|
140
|
+
backgroundColor: colors.dark.bgPrimary,
|
|
141
|
+
paddingHorizontal: 32,
|
|
142
|
+
gap: 12,
|
|
143
|
+
},
|
|
144
|
+
loadingText: {
|
|
145
|
+
color: colors.dark.textSecondary,
|
|
146
|
+
fontSize: 14,
|
|
147
|
+
},
|
|
148
|
+
error: {
|
|
149
|
+
flex: 1,
|
|
150
|
+
justifyContent: 'center',
|
|
151
|
+
alignItems: 'center',
|
|
152
|
+
backgroundColor: colors.dark.bgPrimary,
|
|
153
|
+
paddingHorizontal: 32,
|
|
154
|
+
gap: 8,
|
|
155
|
+
},
|
|
156
|
+
errorTitle: {
|
|
157
|
+
color: colors.dark.red,
|
|
158
|
+
fontSize: 16,
|
|
159
|
+
fontWeight: '600',
|
|
160
|
+
},
|
|
161
|
+
errorMessage: {
|
|
162
|
+
color: colors.dark.textSecondary,
|
|
163
|
+
fontSize: 14,
|
|
164
|
+
textAlign: 'center',
|
|
165
|
+
},
|
|
166
|
+
retryBtn: {
|
|
167
|
+
marginTop: 12,
|
|
168
|
+
paddingHorizontal: 20,
|
|
169
|
+
paddingVertical: 10,
|
|
170
|
+
backgroundColor: colors.dark.accentSoft,
|
|
171
|
+
borderRadius: 8,
|
|
172
|
+
},
|
|
173
|
+
retryText: {
|
|
174
|
+
color: colors.dark.accent,
|
|
175
|
+
fontSize: 14,
|
|
176
|
+
fontWeight: '600',
|
|
177
|
+
},
|
|
178
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PluginSurface — universal mount that wraps any platform-declared plugin
|
|
3
|
+
* (iframe or React Native) inside the right Ada-themed surface container.
|
|
4
|
+
*
|
|
5
|
+
* Mounted when an `engine` or `user` event activates a platform plugin that
|
|
6
|
+
* declared a `surface` field in its manifest. Picks SurfaceDrawer or
|
|
7
|
+
* SurfaceFullscreen based on `surface.type`, fetches the full PluginManifest
|
|
8
|
+
* lazily, and renders <Plugin /> inside the body.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useState } from 'react';
|
|
11
|
+
import { View, ActivityIndicator } from 'react-native';
|
|
12
|
+
import { ada } from '@ateam-ai-mobile/core';
|
|
13
|
+
import {
|
|
14
|
+
getPlugin,
|
|
15
|
+
SurfaceDrawer,
|
|
16
|
+
SurfaceFullscreen,
|
|
17
|
+
type PluginSurfaceMeta,
|
|
18
|
+
} from '@ateam-ai-mobile/plugin-runtime';
|
|
19
|
+
import type { PlatformPluginManifest as PluginManifest } from '@ateam-ai-mobile/plugin-runtime';
|
|
20
|
+
import Plugin from './Plugin';
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
pluginId: string;
|
|
24
|
+
surface: PluginSurfaceMeta;
|
|
25
|
+
dismiss: () => void;
|
|
26
|
+
/** Engine-supplied props forwarded from sys.focusUiPlugin(id, props). */
|
|
27
|
+
props?: any;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default function PluginSurface({ pluginId, surface, dismiss, props }: Props) {
|
|
31
|
+
const [manifest, setManifest] = useState<PluginManifest | null>(null);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
let cancelled = false;
|
|
36
|
+
getPlugin(pluginId)
|
|
37
|
+
.then(m => { if (!cancelled) m ? setManifest(m) : setError('Plugin not found'); })
|
|
38
|
+
.catch(e => { if (!cancelled) setError(e?.message || 'Failed to load'); });
|
|
39
|
+
return () => { cancelled = true; };
|
|
40
|
+
}, [pluginId]);
|
|
41
|
+
|
|
42
|
+
const body = manifest ? (
|
|
43
|
+
<Plugin id={pluginId} manifest={manifest as any} props={props} />
|
|
44
|
+
) : (
|
|
45
|
+
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 }}>
|
|
46
|
+
{error ? null : <ActivityIndicator color={ada.orange} />}
|
|
47
|
+
</View>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (surface.type === 'fullscreen') {
|
|
51
|
+
return (
|
|
52
|
+
<SurfaceFullscreen
|
|
53
|
+
visible={true}
|
|
54
|
+
onClose={dismiss}
|
|
55
|
+
status={surface.title}
|
|
56
|
+
showWordmark
|
|
57
|
+
closeIcon="x"
|
|
58
|
+
closeLabel="Close"
|
|
59
|
+
>
|
|
60
|
+
{body}
|
|
61
|
+
</SurfaceFullscreen>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Default: drawer
|
|
66
|
+
return (
|
|
67
|
+
<SurfaceDrawer
|
|
68
|
+
visible={true}
|
|
69
|
+
onClose={dismiss}
|
|
70
|
+
title={surface.title || pluginId}
|
|
71
|
+
subtitle={surface.subtitle}
|
|
72
|
+
privacy={!!surface.privacy}
|
|
73
|
+
>
|
|
74
|
+
{body}
|
|
75
|
+
</SurfaceDrawer>
|
|
76
|
+
);
|
|
77
|
+
}
|