yaver-feedback-react-native 0.8.12 → 0.8.13

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/src/P2PClient.ts CHANGED
@@ -31,7 +31,7 @@ export interface ReloadAck {
31
31
  * NativeModules. None of the lookups throw — missing data just means
32
32
  * the agent will fall back to its own dev-server resolution.
33
33
  */
34
- function resolveAppIdentity(opts?: {
34
+ export function resolveAppIdentity(opts?: {
35
35
  projectName?: string;
36
36
  bundleId?: string;
37
37
  projectPath?: string;
@@ -807,6 +807,202 @@ export class P2PClient {
807
807
  }
808
808
 
809
809
  /** Internal helper for authenticated GET/POST requests. */
810
+ /**
811
+ * Convergence point for ALL feedback surfaces — Tasks tab, in-Yaver
812
+ * native pane, and this standalone SDK all POST the same shape to
813
+ * `/tasks`. Wraps the user's text with the shared prompt builder
814
+ * (see `_core/buildFeedbackPrompt`) so every surface conditions
815
+ * the AI the same way.
816
+ *
817
+ * Returns the agent's response payload (`taskId`, etc.) so callers
818
+ * can wire `streamTaskOutput()` next for live transcript.
819
+ *
820
+ * Inputs:
821
+ * - userPrompt what the user typed
822
+ * - projectName / path optional Hot-Reload project context
823
+ * - runner / model optional preferred coding agent + model
824
+ * - screenshotBase64 optional JPEG base64 (no `data:` prefix)
825
+ * - imageMimeType defaults to "image/jpeg"
826
+ */
827
+ async createFeedbackTask(input: {
828
+ userPrompt: string;
829
+ projectName?: string;
830
+ projectPath?: string;
831
+ runner?: string;
832
+ model?: string;
833
+ screenshotBase64?: string;
834
+ imageMimeType?: string;
835
+ }): Promise<{ taskId: string; raw?: unknown }> {
836
+ const { buildFeedbackPrompt } = await import('./_core/buildFeedbackPrompt');
837
+ const hasScreenshot = !!(input.screenshotBase64 && input.screenshotBase64.length > 0);
838
+ const description = buildFeedbackPrompt({
839
+ userPrompt: input.userPrompt,
840
+ projectName: input.projectName,
841
+ projectPath: input.projectPath,
842
+ hasScreenshot,
843
+ });
844
+ const images: Array<{ base64: string; mimeType: string; filename: string }> = [];
845
+ if (hasScreenshot && input.screenshotBase64) {
846
+ images.push({
847
+ base64: input.screenshotBase64,
848
+ mimeType: input.imageMimeType ?? 'image/jpeg',
849
+ filename: `yaver-feedback-${Math.floor(Date.now() / 1000)}.jpg`,
850
+ });
851
+ }
852
+ const body: Record<string, unknown> = {
853
+ title: input.userPrompt.slice(0, 80),
854
+ description,
855
+ userPrompt: input.userPrompt,
856
+ source: 'mobile-feedback',
857
+ images,
858
+ };
859
+ if (input.projectPath && input.projectPath.trim()) body.workDir = input.projectPath.trim();
860
+ if (input.projectName && input.projectName.trim()) body.projectName = input.projectName.trim();
861
+ if (input.runner && input.runner.trim()) body.runner = input.runner.trim();
862
+ if (input.model && input.model.trim()) body.model = input.model.trim();
863
+
864
+ const resp = await fetch(`${this.baseUrl}/tasks`, {
865
+ method: 'POST',
866
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
867
+ body: JSON.stringify(body),
868
+ });
869
+ if (!resp.ok) {
870
+ const text = await resp.text().catch(() => '');
871
+ throw new Error(`createFeedbackTask HTTP ${resp.status}: ${text}`);
872
+ }
873
+ const json = (await resp.json().catch(() => ({}))) as { taskId?: string };
874
+ if (!json.taskId) {
875
+ throw new Error('createFeedbackTask: agent did not return taskId');
876
+ }
877
+ return { taskId: json.taskId, raw: json };
878
+ }
879
+
880
+ /**
881
+ * Subscribe to a task's live stdout/stderr stream. Returns an abort
882
+ * function — call it to detach. The agent emits NDJSON lines on
883
+ * `/tasks/{id}/output`; we surface each line via `onLine`.
884
+ *
885
+ * `onComplete` fires when the agent reports the task entered a
886
+ * terminal status (completed / failed / stopped). After that the
887
+ * caller should stop calling abort().
888
+ *
889
+ * Robust to fetch streaming on Hermes (streams Body via Response.
890
+ * body.getReader on platforms that support it; falls back to
891
+ * polling `/tasks/{id}` every 750 ms if streaming isn't available).
892
+ */
893
+ streamTaskOutput(
894
+ taskId: string,
895
+ onLine: (line: string) => void,
896
+ onComplete: (status: string) => void,
897
+ ): () => void {
898
+ const ctrl = new AbortController();
899
+ let closed = false;
900
+ const close = () => {
901
+ if (closed) return;
902
+ closed = true;
903
+ try { ctrl.abort(); } catch { /* ignore */ }
904
+ };
905
+
906
+ (async () => {
907
+ try {
908
+ const resp = await fetch(`${this.baseUrl}/tasks/${encodeURIComponent(taskId)}/output`, {
909
+ method: 'GET',
910
+ headers: this.authHeaders({ Accept: 'text/event-stream' }),
911
+ signal: ctrl.signal,
912
+ });
913
+ if (!resp.ok) {
914
+ throw new Error(`streamTaskOutput HTTP ${resp.status}`);
915
+ }
916
+ // RN Hermes: Response.body may be undefined. Fall back to
917
+ // polling final state.
918
+ const body = (resp as unknown as { body?: ReadableStream<Uint8Array> }).body;
919
+ if (!body || typeof body.getReader !== 'function') {
920
+ await pollTaskUntilDone(this, taskId, onLine, onComplete, () => closed);
921
+ return;
922
+ }
923
+ const reader = body.getReader();
924
+ const decoder = new TextDecoder();
925
+ let buf = '';
926
+ while (!closed) {
927
+ const { value, done } = await reader.read();
928
+ if (done) break;
929
+ buf += decoder.decode(value, { stream: true });
930
+ // SSE frames are separated by \n\n; payloads are `data: <json>\n`.
931
+ let idx = buf.indexOf('\n\n');
932
+ while (idx >= 0) {
933
+ const frame = buf.slice(0, idx);
934
+ buf = buf.slice(idx + 2);
935
+ for (const line of frame.split('\n')) {
936
+ if (line.startsWith('data:')) {
937
+ const payload = line.slice(5).trim();
938
+ if (payload) onLine(payload);
939
+ }
940
+ }
941
+ idx = buf.indexOf('\n\n');
942
+ }
943
+ }
944
+ // Stream closed cleanly — query final status.
945
+ try {
946
+ const final = await fetch(
947
+ `${this.baseUrl}/tasks/${encodeURIComponent(taskId)}`,
948
+ { headers: this.authHeaders() },
949
+ );
950
+ const j = (await final.json().catch(() => ({}))) as { status?: string };
951
+ onComplete(j.status ?? 'completed');
952
+ } catch {
953
+ onComplete('completed');
954
+ }
955
+ } catch (e) {
956
+ if (!closed) {
957
+ // Surface the error via onLine so the UI shows it inline,
958
+ // then mark complete so the caller stops waiting.
959
+ onLine(`__error__: ${e instanceof Error ? e.message : String(e)}`);
960
+ onComplete('failed');
961
+ }
962
+ }
963
+ })();
964
+ return close;
965
+ }
966
+
967
+ /**
968
+ * Send a follow-up message into an existing task — multi-turn vibe
969
+ * chat. The agent's `/tasks/{id}/resume` accepts the same shape as
970
+ * `/tasks` (description / userPrompt / images), and the existing
971
+ * task picks back up with the same runner + project context.
972
+ */
973
+ async resumeTask(input: {
974
+ taskId: string;
975
+ userPrompt: string;
976
+ screenshotBase64?: string;
977
+ imageMimeType?: string;
978
+ }): Promise<void> {
979
+ const images: Array<{ base64: string; mimeType: string; filename: string }> = [];
980
+ if (input.screenshotBase64 && input.screenshotBase64.length > 0) {
981
+ images.push({
982
+ base64: input.screenshotBase64,
983
+ mimeType: input.imageMimeType ?? 'image/jpeg',
984
+ filename: `yaver-feedback-followup-${Math.floor(Date.now() / 1000)}.jpg`,
985
+ });
986
+ }
987
+ const resp = await fetch(
988
+ `${this.baseUrl}/tasks/${encodeURIComponent(input.taskId)}/resume`,
989
+ {
990
+ method: 'POST',
991
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
992
+ body: JSON.stringify({
993
+ description: input.userPrompt,
994
+ userPrompt: input.userPrompt,
995
+ source: 'mobile-feedback',
996
+ images,
997
+ }),
998
+ },
999
+ );
1000
+ if (!resp.ok) {
1001
+ const text = await resp.text().catch(() => '');
1002
+ throw new Error(`resumeTask HTTP ${resp.status}: ${text}`);
1003
+ }
1004
+ }
1005
+
810
1006
  private async request(method: string, path: string): Promise<Response> {
811
1007
  const response = await fetch(`${this.baseUrl}${path}`, {
812
1008
  method,
@@ -823,3 +1019,53 @@ export class P2PClient {
823
1019
  return response;
824
1020
  }
825
1021
  }
1022
+
1023
+ /**
1024
+ * Fallback path used by streamTaskOutput when the platform's fetch
1025
+ * returns a Response with no streaming body (older Hermes builds).
1026
+ * Polls `/tasks/{id}` every 750 ms; surfaces newly-appended output
1027
+ * lines via `onLine`, fires `onComplete` when status is terminal.
1028
+ */
1029
+ async function pollTaskUntilDone(
1030
+ client: P2PClient,
1031
+ taskId: string,
1032
+ onLine: (line: string) => void,
1033
+ onComplete: (status: string) => void,
1034
+ isClosed: () => boolean,
1035
+ ): Promise<void> {
1036
+ // Use bracket access to read the private baseUrl/authHeaders without
1037
+ // making them public — confined to this file's scope.
1038
+ const c = client as unknown as {
1039
+ baseUrl: string;
1040
+ authHeaders: (extra?: Record<string, string>) => Record<string, string>;
1041
+ };
1042
+ let lastLen = 0;
1043
+ for (;;) {
1044
+ if (isClosed()) return;
1045
+ try {
1046
+ const r = await fetch(`${c.baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
1047
+ headers: c.authHeaders(),
1048
+ });
1049
+ const j = (await r.json().catch(() => ({}))) as {
1050
+ status?: string;
1051
+ output?: string[] | string;
1052
+ };
1053
+ const all = Array.isArray(j.output) ? j.output : (j.output ? [j.output] : []);
1054
+ const flat = all.join('\n');
1055
+ if (flat.length > lastLen) {
1056
+ const fresh = flat.slice(lastLen);
1057
+ lastLen = flat.length;
1058
+ for (const ln of fresh.split('\n')) {
1059
+ if (ln.length > 0) onLine(ln);
1060
+ }
1061
+ }
1062
+ if (j.status && ['completed', 'failed', 'stopped'].includes(j.status)) {
1063
+ onComplete(j.status);
1064
+ return;
1065
+ }
1066
+ } catch {
1067
+ // Transient — keep polling.
1068
+ }
1069
+ await new Promise((res) => setTimeout(res, 750));
1070
+ }
1071
+ }
@@ -0,0 +1,362 @@
1
+ // VibeChatScreen — the converged chat UI for the standalone feedback
2
+ // SDK. Mirrors Yaver mobile's Tasks tab + the in-Yaver native pane:
3
+ //
4
+ // 1. User sees a live SSE transcript of agent stdout (PhaseStatusLine
5
+ // style "searching… / compiling…" while running, full markdown
6
+ // output once it lands).
7
+ // 2. User can keep vibing — type a follow-up after the first turn
8
+ // lands and POST a /tasks/{id}/resume to multi-turn the same
9
+ // coding session.
10
+ // 3. Reload button at the bottom hits client.reloadApp() so the user
11
+ // can see the change without leaving the chat.
12
+ //
13
+ // State machine:
14
+ // idle — empty, waiting for first prompt (handled by parent screen)
15
+ // running — task is live, transcript streams, follow-up disabled
16
+ // done — task finished, follow-up enabled, Reload prominent
17
+ // failed — same as done but error tinted
18
+
19
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
20
+ import {
21
+ ActivityIndicator,
22
+ ScrollView,
23
+ StyleSheet,
24
+ Text,
25
+ TextInput,
26
+ TouchableOpacity,
27
+ View,
28
+ } from 'react-native';
29
+ import type { P2PClient } from './P2PClient';
30
+
31
+ export type VibeTurnRole = 'user' | 'assistant' | 'status';
32
+
33
+ export interface VibeTurn {
34
+ id: string;
35
+ role: VibeTurnRole;
36
+ text: string;
37
+ timestamp: number;
38
+ }
39
+
40
+ interface Props {
41
+ client: P2PClient;
42
+ initialTaskId: string;
43
+ initialUserPrompt: string;
44
+ onClose?: () => void;
45
+ /** Called when the user taps Reload after a task completes — uses
46
+ * P2PClient.reloadApp() with the active project context. */
47
+ onReload?: () => Promise<void>;
48
+ }
49
+
50
+ export function VibeChatScreen({
51
+ client,
52
+ initialTaskId,
53
+ initialUserPrompt,
54
+ onClose,
55
+ onReload,
56
+ }: Props) {
57
+ const [taskId, setTaskId] = useState(initialTaskId);
58
+ const [turns, setTurns] = useState<VibeTurn[]>(() => [
59
+ {
60
+ id: `user-${Date.now()}`,
61
+ role: 'user',
62
+ text: initialUserPrompt,
63
+ timestamp: Date.now(),
64
+ },
65
+ {
66
+ id: `status-${Date.now()}`,
67
+ role: 'status',
68
+ text: 'starting…',
69
+ timestamp: Date.now(),
70
+ },
71
+ ]);
72
+ const [streamBuffer, setStreamBuffer] = useState('');
73
+ const [status, setStatus] = useState<'running' | 'done' | 'failed'>('running');
74
+ const [followUp, setFollowUp] = useState('');
75
+ const [isResuming, setIsResuming] = useState(false);
76
+ const [isReloading, setIsReloading] = useState(false);
77
+ const scrollRef = useRef<ScrollView | null>(null);
78
+ const abortRef = useRef<(() => void) | null>(null);
79
+
80
+ // Subscribe to the current task's SSE stream. Re-runs whenever the
81
+ // taskId changes (resumeTask reuses the same id, so this only fires
82
+ // once per task — which is fine).
83
+ useEffect(() => {
84
+ let live = true;
85
+ const acc: string[] = [];
86
+ const close = client.streamTaskOutput(
87
+ taskId,
88
+ (line) => {
89
+ if (!live) return;
90
+ // Filter our internal error sentinel from the SSE helper.
91
+ if (line.startsWith('__error__:')) {
92
+ setStatus('failed');
93
+ setStreamBuffer((prev) => prev + (prev ? '\n' : '') + line.slice('__error__:'.length).trim());
94
+ return;
95
+ }
96
+ acc.push(line);
97
+ // Throttle re-renders: flush every ~100ms.
98
+ setStreamBuffer(acc.join('\n'));
99
+ },
100
+ (terminal) => {
101
+ if (!live) return;
102
+ setStatus(terminal === 'completed' ? 'done' : 'failed');
103
+ // Move the buffered stream into a real assistant turn so the
104
+ // user sees a stable render and can scroll back, then clear
105
+ // the buffer for any follow-up.
106
+ setTurns((prev) => {
107
+ const collapsed = acc.join('\n').trim();
108
+ if (!collapsed) return prev.filter((t) => t.role !== 'status');
109
+ const next = prev.filter((t) => t.role !== 'status');
110
+ next.push({
111
+ id: `assistant-${taskId}-${Date.now()}`,
112
+ role: 'assistant',
113
+ text: collapsed,
114
+ timestamp: Date.now(),
115
+ });
116
+ return next;
117
+ });
118
+ setStreamBuffer('');
119
+ },
120
+ );
121
+ abortRef.current = close;
122
+ return () => {
123
+ live = false;
124
+ try { close(); } catch { /* ignore */ }
125
+ };
126
+ }, [client, taskId]);
127
+
128
+ // Auto-scroll the transcript when new content lands.
129
+ useEffect(() => {
130
+ const t = setTimeout(() => {
131
+ scrollRef.current?.scrollToEnd({ animated: true });
132
+ }, 50);
133
+ return () => clearTimeout(t);
134
+ }, [streamBuffer, turns]);
135
+
136
+ const handleSendFollowUp = useCallback(async () => {
137
+ const text = followUp.trim();
138
+ if (!text || isResuming) return;
139
+ setIsResuming(true);
140
+ // Add user turn immediately for snappy UX.
141
+ setTurns((prev) => [
142
+ ...prev,
143
+ { id: `user-${Date.now()}`, role: 'user', text, timestamp: Date.now() },
144
+ { id: `status-${Date.now()}`, role: 'status', text: 'thinking…', timestamp: Date.now() },
145
+ ]);
146
+ setFollowUp('');
147
+ setStatus('running');
148
+ setStreamBuffer('');
149
+ try {
150
+ await client.resumeTask({ taskId, userPrompt: text });
151
+ // resumeTask reuses the same taskId, so the SSE subscription
152
+ // above will pick up the new output stream automatically. To
153
+ // force a fresh subscription we momentarily flip taskId to a
154
+ // sentinel and back; cleaner than tearing down + re-attaching
155
+ // the SSE manually.
156
+ const same = taskId;
157
+ setTaskId(`${same}#`);
158
+ setTimeout(() => setTaskId(same), 0);
159
+ } catch (e) {
160
+ setStatus('failed');
161
+ setTurns((prev) => [
162
+ ...prev.filter((t) => t.role !== 'status'),
163
+ {
164
+ id: `assistant-err-${Date.now()}`,
165
+ role: 'assistant',
166
+ text: `Failed to send follow-up: ${e instanceof Error ? e.message : String(e)}`,
167
+ timestamp: Date.now(),
168
+ },
169
+ ]);
170
+ } finally {
171
+ setIsResuming(false);
172
+ }
173
+ }, [client, followUp, isResuming, taskId]);
174
+
175
+ const handleReload = useCallback(async () => {
176
+ if (isReloading || !onReload) return;
177
+ setIsReloading(true);
178
+ try {
179
+ await onReload();
180
+ } catch (e) {
181
+ setTurns((prev) => [
182
+ ...prev,
183
+ {
184
+ id: `assistant-reload-err-${Date.now()}`,
185
+ role: 'assistant',
186
+ text: `Reload failed: ${e instanceof Error ? e.message : String(e)}`,
187
+ timestamp: Date.now(),
188
+ },
189
+ ]);
190
+ } finally {
191
+ setIsReloading(false);
192
+ }
193
+ }, [isReloading, onReload]);
194
+
195
+ return (
196
+ <View style={styles.container}>
197
+ <View style={styles.header}>
198
+ <Text style={styles.title}>Vibe</Text>
199
+ {onClose && (
200
+ <TouchableOpacity onPress={onClose} accessibilityLabel="Close vibe chat">
201
+ <Text style={styles.close}>✕</Text>
202
+ </TouchableOpacity>
203
+ )}
204
+ </View>
205
+
206
+ <ScrollView
207
+ ref={scrollRef}
208
+ style={styles.transcript}
209
+ contentContainerStyle={styles.transcriptContent}
210
+ keyboardShouldPersistTaps="handled"
211
+ >
212
+ {turns.map((turn) => (
213
+ <View
214
+ key={turn.id}
215
+ style={[
216
+ styles.turn,
217
+ turn.role === 'user' && styles.turnUser,
218
+ turn.role === 'assistant' && styles.turnAssistant,
219
+ turn.role === 'status' && styles.turnStatus,
220
+ ]}
221
+ >
222
+ <Text style={styles.turnText}>{turn.text}</Text>
223
+ </View>
224
+ ))}
225
+ {/* Live streaming buffer rendered as a single trailing
226
+ assistant block while the task is running. Once the task
227
+ terminates the stream is moved into a real turn (above)
228
+ and this block clears. */}
229
+ {streamBuffer && status === 'running' && (
230
+ <View style={[styles.turn, styles.turnAssistant]}>
231
+ <Text style={styles.turnText}>{streamBuffer}</Text>
232
+ </View>
233
+ )}
234
+ {status === 'running' && (
235
+ <View style={styles.spinnerRow}>
236
+ <ActivityIndicator size="small" color="#9ca3af" />
237
+ <Text style={styles.spinnerText}>working…</Text>
238
+ </View>
239
+ )}
240
+ </ScrollView>
241
+
242
+ <View style={styles.footer}>
243
+ <TextInput
244
+ style={styles.input}
245
+ value={followUp}
246
+ onChangeText={setFollowUp}
247
+ placeholder={status === 'running' ? 'wait for the agent…' : 'follow up…'}
248
+ placeholderTextColor="#666"
249
+ editable={status !== 'running' && !isResuming}
250
+ multiline
251
+ />
252
+ <View style={styles.actions}>
253
+ {onReload && (
254
+ <TouchableOpacity
255
+ style={[
256
+ styles.actionBtn,
257
+ styles.reloadBtn,
258
+ (isReloading || status === 'running') && styles.actionBtnDisabled,
259
+ ]}
260
+ onPress={handleReload}
261
+ disabled={isReloading || status === 'running'}
262
+ >
263
+ <Text style={styles.actionText}>
264
+ {isReloading ? 'reloading…' : '⟳ reload'}
265
+ </Text>
266
+ </TouchableOpacity>
267
+ )}
268
+ <TouchableOpacity
269
+ style={[
270
+ styles.actionBtn,
271
+ styles.sendBtn,
272
+ (isResuming || status === 'running' || !followUp.trim()) && styles.actionBtnDisabled,
273
+ ]}
274
+ onPress={handleSendFollowUp}
275
+ disabled={isResuming || status === 'running' || !followUp.trim()}
276
+ >
277
+ <Text style={styles.actionText}>
278
+ {isResuming ? '…' : '↑ send'}
279
+ </Text>
280
+ </TouchableOpacity>
281
+ </View>
282
+ </View>
283
+ </View>
284
+ );
285
+ }
286
+
287
+ const styles = StyleSheet.create({
288
+ container: { flex: 1, backgroundColor: '#0a0a0a' },
289
+ header: {
290
+ flexDirection: 'row',
291
+ alignItems: 'center',
292
+ justifyContent: 'space-between',
293
+ paddingHorizontal: 16,
294
+ paddingTop: 14,
295
+ paddingBottom: 8,
296
+ borderBottomWidth: 1,
297
+ borderBottomColor: 'rgba(255,255,255,0.08)',
298
+ },
299
+ title: { color: '#fff', fontSize: 17, fontWeight: '600' },
300
+ close: { color: '#9ca3af', fontSize: 18 },
301
+ transcript: { flex: 1 },
302
+ transcriptContent: { padding: 12, paddingBottom: 24 },
303
+ turn: {
304
+ marginVertical: 4,
305
+ padding: 10,
306
+ borderRadius: 12,
307
+ maxWidth: '92%',
308
+ },
309
+ turnUser: {
310
+ backgroundColor: '#7582f5',
311
+ alignSelf: 'flex-end',
312
+ },
313
+ turnAssistant: {
314
+ backgroundColor: 'rgba(255,255,255,0.06)',
315
+ borderColor: 'rgba(255,255,255,0.10)',
316
+ borderWidth: 1,
317
+ alignSelf: 'flex-start',
318
+ },
319
+ turnStatus: {
320
+ backgroundColor: 'transparent',
321
+ alignSelf: 'flex-start',
322
+ paddingHorizontal: 4,
323
+ },
324
+ turnText: { color: '#f1f5f9', fontSize: 14, lineHeight: 20 },
325
+ spinnerRow: {
326
+ flexDirection: 'row',
327
+ alignItems: 'center',
328
+ marginTop: 8,
329
+ paddingHorizontal: 4,
330
+ },
331
+ spinnerText: { color: '#9ca3af', fontSize: 12, marginLeft: 8 },
332
+ footer: {
333
+ borderTopWidth: 1,
334
+ borderTopColor: 'rgba(255,255,255,0.08)',
335
+ padding: 10,
336
+ },
337
+ input: {
338
+ minHeight: 40,
339
+ maxHeight: 120,
340
+ color: '#f1f5f9',
341
+ fontSize: 14,
342
+ backgroundColor: 'rgba(255,255,255,0.04)',
343
+ borderRadius: 10,
344
+ paddingHorizontal: 12,
345
+ paddingVertical: 8,
346
+ },
347
+ actions: {
348
+ flexDirection: 'row',
349
+ justifyContent: 'flex-end',
350
+ marginTop: 8,
351
+ },
352
+ actionBtn: {
353
+ paddingHorizontal: 14,
354
+ paddingVertical: 8,
355
+ borderRadius: 10,
356
+ marginLeft: 8,
357
+ },
358
+ actionBtnDisabled: { opacity: 0.5 },
359
+ reloadBtn: { backgroundColor: 'rgba(255,255,255,0.08)' },
360
+ sendBtn: { backgroundColor: '#7582f5' },
361
+ actionText: { color: '#fff', fontSize: 13, fontWeight: '600' },
362
+ });