@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.
@@ -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
+ }