@ateam-ai-mobile/ui 0.1.2 → 0.1.4
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 +11 -4
- package/dist/index.d.ts +11 -4
- package/dist/index.js +91 -35
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +102 -46
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/chat/JobProgressBar.tsx +57 -28
- package/src/chat/MessageBubble.tsx +26 -4
- package/src/voice/VoiceCallSheet.tsx +4 -2
- package/src/voice/useVoiceCall.ts +51 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ateam-ai-mobile/ui",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Prebuilt ADAS chat experience for React Native
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Prebuilt ADAS chat experience for React Native — full-screen chat, chips, widget views. Built on @ateam-ai-mobile/core + @ateam-ai-mobile/plugin-runtime.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"build": "tsup"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@ateam-ai-mobile/core": "0.1.
|
|
13
|
-
"@ateam-ai-mobile/plugin-runtime": "0.1.
|
|
12
|
+
"@ateam-ai-mobile/core": "^0.1.4",
|
|
13
|
+
"@ateam-ai-mobile/plugin-runtime": "^0.1.4",
|
|
14
14
|
"zustand": "^5.0.0"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
|
@@ -31,4 +31,4 @@
|
|
|
31
31
|
"publishConfig": {
|
|
32
32
|
"access": "public"
|
|
33
33
|
}
|
|
34
|
-
}
|
|
34
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { View, Text, Animated, StyleSheet } from 'react-native';
|
|
3
3
|
import type { JobProgress } from '@ateam-ai-mobile/core';
|
|
4
|
-
import { ada, fontSize
|
|
4
|
+
import { ada, fontSize } from '@ateam-ai-mobile/core';
|
|
5
5
|
|
|
6
6
|
// Semantic warm-palette colors for terminal states (kept local — Ada palette
|
|
7
7
|
// is mostly orange/ink, but progress states need a small warm green/red/amber
|
|
@@ -15,9 +15,12 @@ const WARM_AMBER_SOFT = 'rgba(212, 154, 78, 0.16)';
|
|
|
15
15
|
|
|
16
16
|
interface Props {
|
|
17
17
|
progress: JobProgress;
|
|
18
|
+
/** Standalone placeholder (transparent bubble) — drop the top divider so it
|
|
19
|
+
* floats cleanly over the background. */
|
|
20
|
+
standalone?: boolean;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
export default function JobProgressBar({ progress }: Props) {
|
|
23
|
+
export default function JobProgressBar({ progress, standalone }: Props) {
|
|
21
24
|
const pulseAnim = useRef(new Animated.Value(0.5)).current;
|
|
22
25
|
const [elapsed, setElapsed] = useState(0);
|
|
23
26
|
|
|
@@ -34,8 +37,10 @@ export default function JobProgressBar({ progress }: Props) {
|
|
|
34
37
|
}
|
|
35
38
|
const anim = Animated.loop(
|
|
36
39
|
Animated.sequence([
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
// Gentle breath — a soft fade rather than a hard blink, so the running
|
|
41
|
+
// indicator recedes into the bubble instead of demanding attention.
|
|
42
|
+
Animated.timing(pulseAnim, { toValue: 0.55, duration: 1100, useNativeDriver: true }),
|
|
43
|
+
Animated.timing(pulseAnim, { toValue: 0.22, duration: 1100, useNativeDriver: true }),
|
|
39
44
|
]),
|
|
40
45
|
);
|
|
41
46
|
anim.start();
|
|
@@ -75,7 +80,7 @@ export default function JobProgressBar({ progress }: Props) {
|
|
|
75
80
|
// Terminal states — simple one-liner
|
|
76
81
|
if (isDone || isError) {
|
|
77
82
|
return (
|
|
78
|
-
<View style={styles.container}>
|
|
83
|
+
<View style={[styles.container, standalone && styles.containerStandalone]}>
|
|
79
84
|
<View style={styles.row}>
|
|
80
85
|
<View style={[styles.dot, { backgroundColor: statusColor }]} />
|
|
81
86
|
<Text style={[styles.label, { color: statusColor }]}>
|
|
@@ -88,7 +93,7 @@ export default function JobProgressBar({ progress }: Props) {
|
|
|
88
93
|
|
|
89
94
|
if (isPaused) {
|
|
90
95
|
return (
|
|
91
|
-
<View style={styles.container}>
|
|
96
|
+
<View style={[styles.container, standalone && styles.containerStandalone]}>
|
|
92
97
|
<View style={styles.row}>
|
|
93
98
|
<View style={[styles.dot, { backgroundColor: statusColor }]} />
|
|
94
99
|
<Text style={[styles.label, { color: statusColor }]}>
|
|
@@ -99,10 +104,13 @@ export default function JobProgressBar({ progress }: Props) {
|
|
|
99
104
|
);
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
// Running
|
|
103
|
-
//
|
|
107
|
+
// Running. Prefer the backend's warm, localized humanStatus; fall back to
|
|
108
|
+
// the raw skill/tool tokens (translated via humanLabels when available).
|
|
109
|
+
// Elapsed shows in human units: "3s", "1m 20s". (Docs/STATUS_I18N_MOBILE_API)
|
|
104
110
|
const elapsedStr = progress.chainStartedAt
|
|
105
|
-
?
|
|
111
|
+
? elapsed < 60
|
|
112
|
+
? `${elapsed}s`
|
|
113
|
+
: `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`
|
|
106
114
|
: null;
|
|
107
115
|
|
|
108
116
|
const metaParts = [
|
|
@@ -110,20 +118,26 @@ export default function JobProgressBar({ progress }: Props) {
|
|
|
110
118
|
elapsedStr,
|
|
111
119
|
].filter(Boolean) as string[];
|
|
112
120
|
|
|
121
|
+
const labels = progress.humanLabels;
|
|
122
|
+
const label = (t?: string) => (t && labels?.[t]) || t;
|
|
123
|
+
const headline = progress.humanStatus || label(progress.skillSlug) || 'Processing...';
|
|
124
|
+
// When we already have a warm headline, don't also show the raw "→ tool".
|
|
125
|
+
const showRawTool = !progress.humanStatus && !!progress.currentTool;
|
|
126
|
+
|
|
113
127
|
const lp = progress.liveProgress;
|
|
114
128
|
|
|
115
129
|
return (
|
|
116
|
-
<View style={styles.container}>
|
|
130
|
+
<View style={[styles.container, standalone && styles.containerStandalone]}>
|
|
117
131
|
<View style={styles.row}>
|
|
118
132
|
<Animated.View
|
|
119
|
-
style={[styles.dot,
|
|
133
|
+
style={[styles.dot, styles.dotRunning, { opacity: pulseAnim }]}
|
|
120
134
|
/>
|
|
121
135
|
<Text style={styles.skill} numberOfLines={1}>
|
|
122
|
-
{
|
|
136
|
+
{headline}
|
|
123
137
|
</Text>
|
|
124
|
-
{
|
|
138
|
+
{showRawTool && (
|
|
125
139
|
<Text style={styles.tool} numberOfLines={1}>
|
|
126
|
-
{` → ${progress.currentTool}`}
|
|
140
|
+
{` → ${label(progress.currentTool)}`}
|
|
127
141
|
</Text>
|
|
128
142
|
)}
|
|
129
143
|
{metaParts.length > 0 && (
|
|
@@ -141,9 +155,9 @@ export default function JobProgressBar({ progress }: Props) {
|
|
|
141
155
|
<Text style={styles.liveProgressMsg} numberOfLines={1}>{lp.message}</Text>
|
|
142
156
|
</View>
|
|
143
157
|
)}
|
|
144
|
-
<View style={
|
|
158
|
+
<View style={styles.progressTrack}>
|
|
145
159
|
<Animated.View
|
|
146
|
-
style={[styles.progressFill, {
|
|
160
|
+
style={[styles.progressFill, { opacity: pulseAnim }]}
|
|
147
161
|
/>
|
|
148
162
|
</View>
|
|
149
163
|
</View>
|
|
@@ -157,6 +171,12 @@ const styles = StyleSheet.create({
|
|
|
157
171
|
borderTopWidth: 1,
|
|
158
172
|
borderTopColor: ada.border,
|
|
159
173
|
},
|
|
174
|
+
// Standalone (transparent-bubble) placeholder — no divider/top spacing.
|
|
175
|
+
containerStandalone: {
|
|
176
|
+
marginTop: 0,
|
|
177
|
+
paddingTop: 0,
|
|
178
|
+
borderTopWidth: 0,
|
|
179
|
+
},
|
|
160
180
|
row: {
|
|
161
181
|
flexDirection: 'row',
|
|
162
182
|
alignItems: 'center',
|
|
@@ -164,21 +184,26 @@ const styles = StyleSheet.create({
|
|
|
164
184
|
overflow: 'hidden',
|
|
165
185
|
},
|
|
166
186
|
dot: {
|
|
167
|
-
width:
|
|
168
|
-
height:
|
|
169
|
-
borderRadius:
|
|
187
|
+
width: 6,
|
|
188
|
+
height: 6,
|
|
189
|
+
borderRadius: 3,
|
|
170
190
|
marginRight: 6,
|
|
171
191
|
flexShrink: 0,
|
|
172
192
|
},
|
|
193
|
+
// Running dot: soft orange, not the full-saturation accent.
|
|
194
|
+
dotRunning: {
|
|
195
|
+
backgroundColor: 'rgba(255, 122, 40, 0.6)',
|
|
196
|
+
},
|
|
173
197
|
label: {
|
|
174
198
|
fontSize: fontSize.xs,
|
|
175
199
|
fontWeight: '600',
|
|
176
200
|
},
|
|
177
|
-
// Running state text segments
|
|
201
|
+
// Running state text segments — muted so the indicator blends into the
|
|
202
|
+
// bubble rather than shouting in orange.
|
|
178
203
|
skill: {
|
|
179
204
|
fontSize: fontSize.xs,
|
|
180
|
-
fontWeight: '
|
|
181
|
-
color: ada.
|
|
205
|
+
fontWeight: '500',
|
|
206
|
+
color: ada.inkSoft,
|
|
182
207
|
flexShrink: 1,
|
|
183
208
|
},
|
|
184
209
|
tool: {
|
|
@@ -204,18 +229,22 @@ const styles = StyleSheet.create({
|
|
|
204
229
|
},
|
|
205
230
|
liveProgressMsg: {
|
|
206
231
|
fontSize: fontSize.xs,
|
|
207
|
-
color: ada.
|
|
232
|
+
color: ada.inkSoft,
|
|
208
233
|
flex: 1,
|
|
209
234
|
},
|
|
210
235
|
progressTrack: {
|
|
211
|
-
height:
|
|
212
|
-
borderRadius:
|
|
213
|
-
marginTop:
|
|
236
|
+
height: 2,
|
|
237
|
+
borderRadius: 1,
|
|
238
|
+
marginTop: 8,
|
|
214
239
|
overflow: 'hidden',
|
|
240
|
+
// Barely-there rail so the bar reads as a hint, not a status meter.
|
|
241
|
+
backgroundColor: 'rgba(26, 20, 16, 0.05)',
|
|
215
242
|
},
|
|
216
243
|
progressFill: {
|
|
217
244
|
height: '100%',
|
|
218
|
-
width: '
|
|
219
|
-
borderRadius:
|
|
245
|
+
width: '55%',
|
|
246
|
+
borderRadius: 1,
|
|
247
|
+
// Muted orange — present but recessive.
|
|
248
|
+
backgroundColor: 'rgba(255, 122, 40, 0.4)',
|
|
220
249
|
},
|
|
221
250
|
});
|
|
@@ -73,6 +73,14 @@ export default function MessageBubble({ message }: Props) {
|
|
|
73
73
|
const isUser = message.role === 'user';
|
|
74
74
|
const isSystem = message.role === 'system';
|
|
75
75
|
const isNotification = !isUser && message.kind === 'notification';
|
|
76
|
+
// A "working on it" placeholder — just the live chain indicator, no real
|
|
77
|
+
// answer yet. Render it near-transparent so it floats over the background
|
|
78
|
+
// instead of looking like a solid message bubble.
|
|
79
|
+
const st = message.jobProgress?.status;
|
|
80
|
+
const isProgressOnly =
|
|
81
|
+
!isUser &&
|
|
82
|
+
(st === 'running' || st === 'paused') &&
|
|
83
|
+
(!message.content?.trim() || message.content === 'Working on it...');
|
|
76
84
|
const [copied, setCopied] = useState('');
|
|
77
85
|
|
|
78
86
|
const showCopied = useCallback((label: string) => {
|
|
@@ -113,6 +121,7 @@ export default function MessageBubble({ message }: Props) {
|
|
|
113
121
|
isUser && styles.bubbleUser,
|
|
114
122
|
isSystem && styles.bubbleSystem,
|
|
115
123
|
isNotification && styles.bubbleNotification,
|
|
124
|
+
isProgressOnly && styles.bubbleProgress,
|
|
116
125
|
]}
|
|
117
126
|
>
|
|
118
127
|
{isNotification && (
|
|
@@ -157,6 +166,9 @@ export default function MessageBubble({ message }: Props) {
|
|
|
157
166
|
message.content && !(message.content === 'See attachment' && message.attachments?.length) ? (
|
|
158
167
|
<Text selectable style={styles.userText}>{message.content}</Text>
|
|
159
168
|
) : null
|
|
169
|
+
) : isProgressOnly ? (
|
|
170
|
+
// No "Working on it…" text — the indicator below is the whole message.
|
|
171
|
+
null
|
|
160
172
|
) : (
|
|
161
173
|
renderAssistantContent(message.content, copyText)
|
|
162
174
|
)}
|
|
@@ -169,14 +181,16 @@ export default function MessageBubble({ message }: Props) {
|
|
|
169
181
|
<ToolCallsBadge calls={message.toolCalls} />
|
|
170
182
|
)}
|
|
171
183
|
{message.jobProgress && (
|
|
172
|
-
<JobProgressBar progress={message.jobProgress} />
|
|
184
|
+
<JobProgressBar progress={message.jobProgress} standalone={isProgressOnly} />
|
|
173
185
|
)}
|
|
174
186
|
{!isUser && message.actions && message.actions.length > 0 && (
|
|
175
187
|
<UIActionsRow actions={message.actions} />
|
|
176
188
|
)}
|
|
177
|
-
|
|
178
|
-
{
|
|
179
|
-
|
|
189
|
+
{!isProgressOnly && (
|
|
190
|
+
<Text style={[styles.timeText, isUser && styles.timeTextUser]}>
|
|
191
|
+
{formatTime(message.timestamp)}
|
|
192
|
+
</Text>
|
|
193
|
+
)}
|
|
180
194
|
</Pressable>
|
|
181
195
|
{isUser && <View style={styles.avatarSpacer} />}
|
|
182
196
|
</View>
|
|
@@ -323,6 +337,14 @@ const styles = StyleSheet.create({
|
|
|
323
337
|
backgroundColor: ada.userBubble,
|
|
324
338
|
borderColor: 'transparent',
|
|
325
339
|
},
|
|
340
|
+
// Live-chain placeholder: no fill, no border, tighter padding — the
|
|
341
|
+
// indicator floats over the background instead of sitting in a bubble.
|
|
342
|
+
bubbleProgress: {
|
|
343
|
+
backgroundColor: 'transparent',
|
|
344
|
+
borderColor: 'transparent',
|
|
345
|
+
paddingHorizontal: 4,
|
|
346
|
+
paddingVertical: 2,
|
|
347
|
+
},
|
|
326
348
|
bubbleSystem: {
|
|
327
349
|
backgroundColor: ada.orangeSoft,
|
|
328
350
|
borderColor: ada.orange,
|
|
@@ -23,6 +23,8 @@ import useVoiceCall from './useVoiceCall';
|
|
|
23
23
|
interface Props {
|
|
24
24
|
visible: boolean;
|
|
25
25
|
onClose: () => void;
|
|
26
|
+
/** Optional skill to hand the mic to on connect (voice /session → takeMic). */
|
|
27
|
+
skillSlug?: string;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
|
|
@@ -88,12 +90,12 @@ function HighlightedText({
|
|
|
88
90
|
);
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
export default function VoiceCallSheet({ visible, onClose }: Props) {
|
|
93
|
+
export default function VoiceCallSheet({ visible, onClose, skillSlug }: Props) {
|
|
92
94
|
const {
|
|
93
95
|
callState, aiPartial, userPartial, transcript, error, muted,
|
|
94
96
|
startCall, stopCall, toggleMute, handleWebViewMessage, getWebViewSource, webViewRef,
|
|
95
97
|
injectedTrackHook,
|
|
96
|
-
} = useVoiceCall();
|
|
98
|
+
} = useVoiceCall({ skillSlug });
|
|
97
99
|
|
|
98
100
|
const scrollRef = useRef<ScrollView>(null);
|
|
99
101
|
|
|
@@ -7,7 +7,36 @@
|
|
|
7
7
|
* without it. Identity (token/tenant/actor) comes from the host-owned config.
|
|
8
8
|
*/
|
|
9
9
|
import { useState, useRef, useCallback } from 'react';
|
|
10
|
-
import { getVoiceApiUrl, getToken, getTenant, getUser } from '@ateam-ai-mobile/core';
|
|
10
|
+
import { getVoiceApiUrl, getToken, getTenant, getUser, refreshToken } from '@ateam-ai-mobile/core';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The voice WebView bakes the JWT into its URL once at mount and never
|
|
14
|
+
* refreshes it (unlike the SDK REST client, which self-heals on 401). So a
|
|
15
|
+
* token that has gone stale by the time the user taps the mic is handed to
|
|
16
|
+
* voice-backend and rejected by Core /api/auth/me ("Invalid or expired
|
|
17
|
+
* token"). Guard: if the current token is expired or within `SKEW_MS` of
|
|
18
|
+
* expiry, refresh it BEFORE the WebView mounts so it gets a fresh token.
|
|
19
|
+
* Cheap in the common case — we only pay the round-trip when actually near
|
|
20
|
+
* expiry (decoded locally, no network unless needed).
|
|
21
|
+
*/
|
|
22
|
+
const TOKEN_REFRESH_SKEW_MS = 90_000; // refresh if <90s of life left (or expired)
|
|
23
|
+
function tokenNeedsRefresh(token: string | null): boolean {
|
|
24
|
+
if (!token) return true;
|
|
25
|
+
try {
|
|
26
|
+
const payloadB64 = token.split('.')[1];
|
|
27
|
+
const padded = payloadB64.replace(/-/g, '+').replace(/_/g, '/');
|
|
28
|
+
// Hermes has atob; Buffer fallback for older runtimes (mirrors
|
|
29
|
+
// apps/mobile authStore.decodeJwtPayload).
|
|
30
|
+
const json = typeof atob === 'function'
|
|
31
|
+
? atob(padded)
|
|
32
|
+
: Buffer.from(padded, 'base64').toString('utf8');
|
|
33
|
+
const exp = JSON.parse(json)?.exp;
|
|
34
|
+
if (typeof exp !== 'number') return false;
|
|
35
|
+
return Date.now() >= exp * 1000 - TOKEN_REFRESH_SKEW_MS;
|
|
36
|
+
} catch {
|
|
37
|
+
return false; // can't decode → let the server decide, don't block the call
|
|
38
|
+
}
|
|
39
|
+
}
|
|
11
40
|
|
|
12
41
|
export interface TranscriptEntry {
|
|
13
42
|
role: 'user' | 'ai' | 'tool';
|
|
@@ -113,7 +142,7 @@ const buildMuteScript = (muted: boolean) => `
|
|
|
113
142
|
})(${muted}); true;
|
|
114
143
|
`;
|
|
115
144
|
|
|
116
|
-
export default function useVoiceCall() {
|
|
145
|
+
export default function useVoiceCall({ skillSlug }: { skillSlug?: string } = {}) {
|
|
117
146
|
const [callState, setCallState] = useState<CallState>('idle');
|
|
118
147
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
119
148
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -156,12 +185,25 @@ export default function useVoiceCall() {
|
|
|
156
185
|
} catch {}
|
|
157
186
|
}, []);
|
|
158
187
|
|
|
159
|
-
const startCall = useCallback(() => {
|
|
160
|
-
setCallState('connecting');
|
|
188
|
+
const startCall = useCallback(async () => {
|
|
161
189
|
setError(null);
|
|
162
190
|
setTranscript([]);
|
|
163
191
|
setAiPartial('');
|
|
164
192
|
setUserPartial('');
|
|
193
|
+
// Ensure the WebView is handed a FRESH token BEFORE it mounts (it never
|
|
194
|
+
// refreshes once mounted, and the WebView mounts as soon as callState
|
|
195
|
+
// leaves 'idle'). Only pays a round-trip when the token is expired or
|
|
196
|
+
// near-expiry; otherwise instant. Flip to 'connecting' only after, so
|
|
197
|
+
// getWebViewSource() reads the refreshed token.
|
|
198
|
+
try {
|
|
199
|
+
if (tokenNeedsRefresh(getToken())) {
|
|
200
|
+
await refreshToken();
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// Refresh failed — proceed with whatever token we have; voice-backend
|
|
204
|
+
// will surface a clean auth error rather than us blocking the call.
|
|
205
|
+
}
|
|
206
|
+
setCallState('connecting');
|
|
165
207
|
}, []);
|
|
166
208
|
|
|
167
209
|
const stopCall = useCallback(() => {
|
|
@@ -187,10 +229,13 @@ export default function useVoiceCall() {
|
|
|
187
229
|
const token = getToken();
|
|
188
230
|
const tenant = getTenant();
|
|
189
231
|
const actorId = getUser()?.actorId || '';
|
|
232
|
+
// skillSlug (when opened for a specific skill) is read by the voice mini-app
|
|
233
|
+
// and posted to /session, which turns it into takeMic (E1 held-mic entry).
|
|
234
|
+
const skillParam = skillSlug ? `&skillSlug=${encodeURIComponent(skillSlug)}` : '';
|
|
190
235
|
return {
|
|
191
|
-
uri: `${getVoiceApiUrl()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || '')}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}`,
|
|
236
|
+
uri: `${getVoiceApiUrl()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || '')}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}`,
|
|
192
237
|
};
|
|
193
|
-
}, []);
|
|
238
|
+
}, [skillSlug]);
|
|
194
239
|
|
|
195
240
|
return {
|
|
196
241
|
callState, sessionId, error, transcript, aiPartial, userPartial, muted,
|