@10play/expo-air 0.12.0 → 0.12.2

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.
@@ -1,6 +1,7 @@
1
1
  import React, { useRef, useEffect, useState, useCallback } from "react";
2
- import { View, Text, ScrollView, StyleSheet, TouchableOpacity, NativeScrollEvent, Keyboard, Platform, Image, Linking } from "react-native";
3
- import type { ServerMessage, ToolMessage, ResultMessage, UserPromptMessage, HistoryResultMessage, SystemDisplayMessage, AssistantPart, AssistantPartsMessage } from "../services/websocket";
2
+ import { View, Text, ScrollView, StyleSheet, TouchableOpacity, NativeScrollEvent, Keyboard, Platform } from "react-native";
3
+ import type { ServerMessage, AssistantPart } from "../services/websocket";
4
+ import { MessageItem, PartsRenderer } from "./MessageItems";
4
5
  import { SPACING, LAYOUT, COLORS, TYPOGRAPHY } from "../constants/design";
5
6
 
6
7
  interface ResponseAreaProps {
@@ -15,21 +16,32 @@ export function ResponseArea({ messages, currentParts }: ResponseAreaProps) {
15
16
  const scrollViewHeightRef = useRef(0);
16
17
 
17
18
  // Track if user is at bottom (within 50px threshold)
18
- const handleScroll = useCallback((event: { nativeEvent: NativeScrollEvent }) => {
19
- const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
20
- const distanceFromBottom = contentSize.height - layoutMeasurement.height - contentOffset.y;
21
- setIsAtBottom(distanceFromBottom < 50);
22
- }, []);
19
+ const handleScroll = useCallback(
20
+ (event: { nativeEvent: NativeScrollEvent }) => {
21
+ const { contentOffset, contentSize, layoutMeasurement } =
22
+ event.nativeEvent;
23
+ const distanceFromBottom =
24
+ contentSize.height - layoutMeasurement.height - contentOffset.y;
25
+ setIsAtBottom(distanceFromBottom < 50);
26
+ },
27
+ [],
28
+ );
23
29
 
24
30
  // Track content size changes
25
- const handleContentSizeChange = useCallback((width: number, height: number) => {
26
- contentHeightRef.current = height;
27
- }, []);
31
+ const handleContentSizeChange = useCallback(
32
+ (width: number, height: number) => {
33
+ contentHeightRef.current = height;
34
+ },
35
+ [],
36
+ );
28
37
 
29
38
  // Track scroll view layout
30
- const handleLayout = useCallback((event: { nativeEvent: { layout: { height: number } } }) => {
31
- scrollViewHeightRef.current = event.nativeEvent.layout.height;
32
- }, []);
39
+ const handleLayout = useCallback(
40
+ (event: { nativeEvent: { layout: { height: number } } }) => {
41
+ scrollViewHeightRef.current = event.nativeEvent.layout.height;
42
+ },
43
+ [],
44
+ );
33
45
 
34
46
  // Auto-scroll only when at bottom
35
47
  useEffect(() => {
@@ -53,7 +65,8 @@ export function ResponseArea({ messages, currentParts }: ResponseAreaProps) {
53
65
 
54
66
  // Scroll to bottom when keyboard opens (if already at bottom)
55
67
  useEffect(() => {
56
- const keyboardEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
68
+ const keyboardEvent =
69
+ Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
57
70
  const subscription = Keyboard.addListener(keyboardEvent, () => {
58
71
  if (isAtBottom) {
59
72
  // Delay to let the native resize happen first
@@ -102,7 +115,11 @@ export function ResponseArea({ messages, currentParts }: ResponseAreaProps) {
102
115
  )}
103
116
  </ScrollView>
104
117
  {!isAtBottom && (
105
- <TouchableOpacity style={styles.scrollButton} onPress={scrollToBottom} activeOpacity={0.8}>
118
+ <TouchableOpacity
119
+ style={styles.scrollButton}
120
+ onPress={scrollToBottom}
121
+ activeOpacity={0.8}
122
+ >
106
123
  <Text style={styles.scrollButtonText}>↓</Text>
107
124
  </TouchableOpacity>
108
125
  )}
@@ -110,384 +127,6 @@ export function ResponseArea({ messages, currentParts }: ResponseAreaProps) {
110
127
  );
111
128
  }
112
129
 
113
- function MessageItem({ message }: { key?: React.Key; message: ServerMessage }) {
114
- switch (message.type) {
115
- case "stream":
116
- return null; // Handled by currentParts
117
-
118
- case "tool":
119
- // Legacy: individual tool messages from history
120
- return <ToolItem tool={message} />;
121
-
122
- case "result":
123
- return <ResultItem result={message} />;
124
-
125
- case "error":
126
- return (
127
- <View style={styles.errorContainer}>
128
- <Text style={styles.errorText}>{message.message}</Text>
129
- </View>
130
- );
131
-
132
- case "status":
133
- return null; // Handled by header
134
-
135
- case "user_prompt":
136
- return <UserPromptItem message={message} />;
137
-
138
- case "history_result":
139
- return <HistoryResultItem message={message} />;
140
-
141
- case "assistant_parts":
142
- return <AssistantPartsItem message={message} />;
143
-
144
- case "system_message":
145
- return <SystemMessageItem message={message} />;
146
-
147
- default:
148
- return null;
149
- }
150
- }
151
-
152
- function UserPromptItem({ message }: { message: UserPromptMessage }) {
153
- return (
154
- <View style={styles.userPromptContainer}>
155
- {message.images && message.images.length > 0 && (
156
- <View style={styles.userImages}>
157
- {message.images.map((img, i) => (
158
- <Image key={i} source={{ uri: img.uri }} style={styles.userImageThumb} />
159
- ))}
160
- </View>
161
- )}
162
- {message.content ? (
163
- <Text style={styles.userPromptText} selectable>{message.content}</Text>
164
- ) : null}
165
- </View>
166
- );
167
- }
168
-
169
- function HistoryResultItem({ message }: { message: HistoryResultMessage }) {
170
- return (
171
- <View style={styles.resultContainer}>
172
- <FormattedText content={message.content} />
173
- </View>
174
- );
175
- }
176
-
177
- function SystemMessageItem({ message }: { message: SystemDisplayMessage }) {
178
- // Use error styling for errors, muted styling for other system messages
179
- if (message.messageType === "error") {
180
- return (
181
- <View style={styles.errorContainer}>
182
- <Text style={styles.errorText}>{message.content}</Text>
183
- </View>
184
- );
185
- }
186
- // Stopped and info messages use muted styling
187
- return (
188
- <View style={styles.systemContainer}>
189
- <Text style={styles.systemText}>{message.content}</Text>
190
- </View>
191
- );
192
- }
193
-
194
- // Renders formatted text with markdown-style lists, code, and emphasis
195
- function FormattedText({ content, isStreaming }: { content: string; isStreaming?: boolean }) {
196
- const lines = content.split('\n');
197
- const elements: React.ReactNode[] = [];
198
- let i = 0;
199
-
200
- while (i < lines.length) {
201
- const line = lines[i];
202
- const trimmed = line.trim();
203
-
204
- // Code block start (check first to avoid matching content inside code blocks)
205
- if (trimmed.startsWith('```')) {
206
- const codeLines: string[] = [];
207
- const lang = trimmed.slice(3).trim();
208
- i++;
209
- while (i < lines.length && !lines[i].trim().startsWith('```')) {
210
- codeLines.push(lines[i]);
211
- i++;
212
- }
213
- elements.push(
214
- <View key={`code-${i}`} style={styles.codeBlock}>
215
- {lang && <Text style={styles.codeLang}>{lang}</Text>}
216
- <Text style={styles.codeText} selectable>{codeLines.join('\n')}</Text>
217
- </View>
218
- );
219
- i++; // skip closing ```
220
- continue;
221
- }
222
-
223
- // Heading (# ## ### etc.)
224
- const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
225
- if (headingMatch) {
226
- const level = headingMatch[1].length;
227
- const headingStyle = level <= 2 ? styles.heading1 : level <= 4 ? styles.heading2 : styles.heading3;
228
- elements.push(
229
- <Text key={i} style={headingStyle} selectable>{formatInlineText(headingMatch[2])}</Text>
230
- );
231
- i++;
232
- continue;
233
- }
234
-
235
- // Blockquote (> text) - collect consecutive > lines
236
- if (trimmed.startsWith('> ') || trimmed === '>') {
237
- const quoteLines: string[] = [];
238
- while (i < lines.length && (lines[i].trim().startsWith('> ') || lines[i].trim() === '>')) {
239
- const qContent = lines[i].trim().startsWith('> ') ? lines[i].trim().slice(2) : '';
240
- quoteLines.push(qContent);
241
- i++;
242
- }
243
- elements.push(
244
- <View key={`quote-${i}`} style={styles.blockquote}>
245
- <Text style={styles.blockquoteText} selectable>{formatInlineText(quoteLines.join('\n'))}</Text>
246
- </View>
247
- );
248
- continue;
249
- }
250
-
251
- // Task list item (- [ ] or - [x])
252
- const taskMatch = trimmed.match(/^[-*]\s+\[([ xX])\]\s+(.+)$/);
253
- if (taskMatch) {
254
- const checked = taskMatch[1] !== ' ';
255
- elements.push(
256
- <View key={i} style={styles.listItem}>
257
- <Text style={styles.listBullet}>{checked ? '☑' : '☐'}</Text>
258
- <Text style={styles.listText} selectable>{formatInlineText(taskMatch[2])}</Text>
259
- </View>
260
- );
261
- i++;
262
- continue;
263
- }
264
-
265
- // Numbered list item (1. 2. 3.) - with nesting via indentation
266
- const numberedMatch = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
267
- if (numberedMatch) {
268
- const indent = Math.floor(numberedMatch[1].length / 2);
269
- const [, , num, text] = numberedMatch;
270
- elements.push(
271
- <View key={i} style={[styles.listItem, indent > 0 && { paddingLeft: SPACING.SM + indent * SPACING.LG }]}>
272
- <Text style={styles.listNumber}>{num}.</Text>
273
- <Text style={styles.listText} selectable>{formatInlineText(text)}</Text>
274
- </View>
275
- );
276
- i++;
277
- continue;
278
- }
279
-
280
- // Bullet list item (- or *) - with nesting via indentation
281
- const bulletMatch = line.match(/^(\s*)[-*]\s+(.+)$/);
282
- if (bulletMatch) {
283
- const indent = Math.floor(bulletMatch[1].length / 2);
284
- const bulletChar = indent === 0 ? '•' : indent === 1 ? '◦' : '▪';
285
- elements.push(
286
- <View key={i} style={[styles.listItem, indent > 0 && { paddingLeft: SPACING.SM + indent * SPACING.LG }]}>
287
- <Text style={styles.listBullet}>{bulletChar}</Text>
288
- <Text style={styles.listText} selectable>{formatInlineText(bulletMatch[2])}</Text>
289
- </View>
290
- );
291
- i++;
292
- continue;
293
- }
294
-
295
- // Horizontal rule (---, ***, ___)
296
- if (/^[-*_]{3,}$/.test(trimmed)) {
297
- elements.push(<View key={i} style={styles.horizontalRule} />);
298
- i++;
299
- continue;
300
- }
301
-
302
- // Empty line = paragraph break
303
- if (!trimmed) {
304
- elements.push(<View key={i} style={styles.paragraphBreak} />);
305
- i++;
306
- continue;
307
- }
308
-
309
- // Regular text
310
- elements.push(
311
- <Text key={i} style={styles.responseText} selectable>{formatInlineText(line)}</Text>
312
- );
313
- i++;
314
- }
315
-
316
- return (
317
- <View style={styles.formattedContainer}>
318
- {elements}
319
- {isStreaming && <View style={styles.cursor} />}
320
- </View>
321
- );
322
- }
323
-
324
- // Format inline elements: code spans first, then links, then emphasis
325
- function formatInlineText(text: string): React.ReactNode {
326
- // Split on inline code first to avoid processing markdown inside code spans
327
- const codeParts = text.split(/(`[^`]+`)/g);
328
- if (codeParts.length === 1) {
329
- return formatLinks(text);
330
- }
331
-
332
- return codeParts.map((part, i) => {
333
- if (part.startsWith('`') && part.endsWith('`')) {
334
- return <Text key={i} style={styles.inlineCode}>{part.slice(1, -1)}</Text>;
335
- }
336
- return <React.Fragment key={i}>{formatLinks(part)}</React.Fragment>;
337
- });
338
- }
339
-
340
- // Format markdown links [text](url)
341
- function formatLinks(text: string): React.ReactNode {
342
- const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g);
343
- if (parts.length === 1) return formatEmphasis(text);
344
-
345
- return parts.map((part, i) => {
346
- const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
347
- if (linkMatch) {
348
- return (
349
- <Text
350
- key={i}
351
- style={styles.linkText}
352
- onPress={() => Linking.openURL(linkMatch[2])}
353
- >
354
- {linkMatch[1]}
355
- </Text>
356
- );
357
- }
358
- return <React.Fragment key={i}>{formatEmphasis(part)}</React.Fragment>;
359
- });
360
- }
361
-
362
- // Format bold, italic, and strikethrough emphasis
363
- function formatEmphasis(text: string): React.ReactNode {
364
- // Match **bold**, ~~strikethrough~~, *italic*, and plain text segments
365
- const parts = text.split(/(\*\*[^*]+\*\*|~~[^~]+~~|\*[^*]+\*)/g);
366
- if (parts.length === 1) return text;
367
-
368
- return parts.map((part, i) => {
369
- if (part.startsWith('**') && part.endsWith('**')) {
370
- return <Text key={i} style={styles.boldText}>{part.slice(2, -2)}</Text>;
371
- }
372
- if (part.startsWith('~~') && part.endsWith('~~')) {
373
- return <Text key={i} style={styles.strikethroughText}>{part.slice(2, -2)}</Text>;
374
- }
375
- if (part.startsWith('*') && part.endsWith('*')) {
376
- return <Text key={i} style={styles.italicText}>{part.slice(1, -1)}</Text>;
377
- }
378
- return part;
379
- });
380
- }
381
-
382
- // Renders interleaved text and tool parts in order
383
- function PartsRenderer({ parts, isStreaming }: { parts: AssistantPart[], isStreaming: boolean }) {
384
- return (
385
- <View style={styles.partsContainer}>
386
- {parts.map((part, index) => {
387
- if (part.type === "text") {
388
- const isLastPart = index === parts.length - 1;
389
- return (
390
- <View key={part.id} style={styles.messageContainer}>
391
- <FormattedText content={part.content} isStreaming={isStreaming && isLastPart} />
392
- </View>
393
- );
394
- } else if (part.type === "tool") {
395
- return <ToolPartItem key={part.id} part={part} />;
396
- }
397
- return null;
398
- })}
399
- </View>
400
- );
401
- }
402
-
403
- // Renders a completed assistant response with parts
404
- function AssistantPartsItem({ message }: { message: AssistantPartsMessage }) {
405
- return (
406
- <View style={styles.resultContainer}>
407
- <PartsRenderer parts={message.parts} isStreaming={false} />
408
- {!message.isComplete && (
409
- <Text style={styles.interruptedText}>(interrupted)</Text>
410
- )}
411
- </View>
412
- );
413
- }
414
-
415
- // Shared helper for tool display info
416
- function getToolDisplayInfo(toolName: string, input: Record<string, unknown> | undefined): { label: string; value: string } {
417
- const getFileName = (path: string): string => path.split('/').pop() || path;
418
-
419
- switch (toolName) {
420
- case "Read":
421
- return { label: "read", value: getFileName(input?.file_path as string || "file") };
422
- case "Edit":
423
- return { label: "edit", value: getFileName(input?.file_path as string || "file") };
424
- case "Write":
425
- return { label: "write", value: getFileName(input?.file_path as string || "file") };
426
- case "Bash": {
427
- const cmd = input?.command as string || "";
428
- return { label: "$", value: cmd.length > 45 ? cmd.slice(0, 45) + "…" : cmd };
429
- }
430
- case "Glob":
431
- return { label: "glob", value: input?.pattern as string || "*" };
432
- case "Grep":
433
- return { label: "grep", value: input?.pattern as string || "search" };
434
- case "Task":
435
- return { label: "agent", value: input?.description as string || "task" };
436
- default:
437
- return { label: toolName.toLowerCase(), value: "" };
438
- }
439
- }
440
-
441
- // Renders a tool display line (shared between ToolPartItem and ToolItem)
442
- function ToolDisplay({ toolName, input, isFailed }: { toolName: string; input?: unknown; isFailed: boolean }) {
443
- const { label, value } = getToolDisplayInfo(toolName, input as Record<string, unknown> | undefined);
444
-
445
- return (
446
- <View style={styles.toolLine}>
447
- <Text style={isFailed ? styles.toolLabelFailed : styles.toolLabel}>{label}</Text>
448
- <Text style={isFailed ? styles.toolValueFailed : styles.toolValue} numberOfLines={1}>{value}</Text>
449
- {isFailed && <Text style={styles.toolLabelFailed}> ✕</Text>}
450
- </View>
451
- );
452
- }
453
-
454
- // Tool part renderer (for parts in AssistantPartsMessage)
455
- function ToolPartItem({ part }: { key?: React.Key; part: AssistantPart & { type: "tool" } }) {
456
- if (part.status === "started") return null;
457
- return <ToolDisplay toolName={part.toolName} input={part.input} isFailed={part.status === "failed"} />;
458
- }
459
-
460
- // Tool item renderer (for legacy ToolMessage from history)
461
- function ToolItem({ tool }: { tool: ToolMessage }) {
462
- if (tool.status === "started") return null;
463
- return <ToolDisplay toolName={tool.toolName} input={tool.input} isFailed={tool.status === "failed"} />;
464
- }
465
-
466
- function ResultItem({ result }: { result: ResultMessage }) {
467
- if (!result.success && result.error) {
468
- return (
469
- <View style={styles.errorContainer}>
470
- <Text style={styles.errorText}>{result.error}</Text>
471
- </View>
472
- );
473
- }
474
-
475
- return (
476
- <View style={styles.resultContainer}>
477
- {result.result && (
478
- <Text style={styles.responseText} selectable>{result.result}</Text>
479
- )}
480
- {(result.costUsd !== undefined || result.durationMs !== undefined) && (
481
- <Text style={styles.metaText}>
482
- {result.durationMs !== undefined && `${result.durationMs}ms`}
483
- {result.costUsd !== undefined && result.durationMs !== undefined && " • "}
484
- {result.costUsd !== undefined && `$${result.costUsd.toFixed(4)}`}
485
- </Text>
486
- )}
487
- </View>
488
- );
489
- }
490
-
491
130
  const styles = StyleSheet.create({
492
131
  wrapper: {
493
132
  flex: 1,
@@ -513,230 +152,6 @@ const styles = StyleSheet.create({
513
152
  textAlign: "center",
514
153
  lineHeight: 22,
515
154
  },
516
- messageContainer: {
517
- flexDirection: "row",
518
- flexWrap: "wrap",
519
- },
520
- responseText: {
521
- color: "rgba(255,255,255,0.95)",
522
- fontSize: TYPOGRAPHY.SIZE_LG,
523
- lineHeight: 22,
524
- },
525
- cursor: {
526
- width: 2,
527
- height: 18,
528
- backgroundColor: COLORS.TEXT_PRIMARY,
529
- marginLeft: 2,
530
- opacity: 0.7,
531
- },
532
- resultContainer: {
533
- marginTop: SPACING.SM,
534
- },
535
- partsContainer: {
536
- // Container for interleaved parts
537
- },
538
- formattedContainer: {
539
- flexDirection: "column",
540
- },
541
- listItem: {
542
- flexDirection: "row",
543
- marginVertical: SPACING.XS / 2,
544
- paddingLeft: SPACING.SM,
545
- },
546
- listNumber: {
547
- color: COLORS.TEXT_MUTED,
548
- fontSize: TYPOGRAPHY.SIZE_LG,
549
- lineHeight: 22,
550
- width: 24,
551
- fontWeight: TYPOGRAPHY.WEIGHT_MEDIUM,
552
- },
553
- listBullet: {
554
- color: COLORS.TEXT_MUTED,
555
- fontSize: TYPOGRAPHY.SIZE_LG,
556
- lineHeight: 22,
557
- width: 18,
558
- },
559
- listText: {
560
- flex: 1,
561
- color: "rgba(255,255,255,0.95)",
562
- fontSize: TYPOGRAPHY.SIZE_LG,
563
- lineHeight: 22,
564
- },
565
- codeBlock: {
566
- backgroundColor: "rgba(255,255,255,0.06)",
567
- borderRadius: SPACING.SM,
568
- padding: SPACING.MD,
569
- marginVertical: SPACING.SM,
570
- },
571
- codeLang: {
572
- color: COLORS.TEXT_MUTED,
573
- fontSize: TYPOGRAPHY.SIZE_XS,
574
- fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
575
- marginBottom: SPACING.XS,
576
- textTransform: "uppercase",
577
- },
578
- codeText: {
579
- color: "rgba(255,255,255,0.85)",
580
- fontSize: TYPOGRAPHY.SIZE_SM,
581
- fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
582
- lineHeight: 18,
583
- },
584
- inlineCode: {
585
- backgroundColor: "rgba(255,255,255,0.1)",
586
- color: "rgba(255,255,255,0.9)",
587
- fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
588
- fontSize: TYPOGRAPHY.SIZE_MD,
589
- paddingHorizontal: 4,
590
- borderRadius: 3,
591
- },
592
- heading1: {
593
- color: COLORS.TEXT_PRIMARY,
594
- fontSize: TYPOGRAPHY.SIZE_XL,
595
- fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
596
- lineHeight: 24,
597
- marginTop: SPACING.MD,
598
- marginBottom: SPACING.XS,
599
- },
600
- heading2: {
601
- color: "rgba(255,255,255,0.9)",
602
- fontSize: TYPOGRAPHY.SIZE_LG,
603
- fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
604
- lineHeight: 22,
605
- marginTop: SPACING.SM,
606
- marginBottom: SPACING.XS,
607
- },
608
- heading3: {
609
- color: "rgba(255,255,255,0.85)",
610
- fontSize: TYPOGRAPHY.SIZE_LG,
611
- fontWeight: TYPOGRAPHY.WEIGHT_MEDIUM,
612
- lineHeight: 22,
613
- marginTop: SPACING.SM,
614
- marginBottom: SPACING.XS,
615
- },
616
- boldText: {
617
- fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
618
- color: COLORS.TEXT_PRIMARY,
619
- },
620
- italicText: {
621
- fontStyle: "italic",
622
- color: "rgba(255,255,255,0.85)",
623
- },
624
- strikethroughText: {
625
- textDecorationLine: "line-through",
626
- color: COLORS.TEXT_MUTED,
627
- },
628
- linkText: {
629
- color: COLORS.STATUS_INFO,
630
- textDecorationLine: "underline",
631
- },
632
- blockquote: {
633
- borderLeftWidth: 3,
634
- borderLeftColor: "rgba(255,255,255,0.15)",
635
- paddingLeft: SPACING.MD,
636
- marginVertical: SPACING.XS,
637
- },
638
- blockquoteText: {
639
- color: COLORS.TEXT_SECONDARY,
640
- fontSize: TYPOGRAPHY.SIZE_LG,
641
- lineHeight: 22,
642
- fontStyle: "italic",
643
- },
644
- horizontalRule: {
645
- height: 1,
646
- backgroundColor: COLORS.BORDER,
647
- marginVertical: SPACING.MD,
648
- },
649
- paragraphBreak: {
650
- height: SPACING.SM,
651
- },
652
- interruptedText: {
653
- color: COLORS.TEXT_MUTED,
654
- fontSize: TYPOGRAPHY.SIZE_XS + 1, // 12px
655
- fontStyle: "italic",
656
- marginTop: SPACING.XS,
657
- },
658
- metaText: {
659
- color: COLORS.TEXT_MUTED,
660
- fontSize: TYPOGRAPHY.SIZE_XS + 1, // 12px
661
- marginTop: SPACING.SM + 2, // 10px
662
- },
663
- errorContainer: {
664
- backgroundColor: "rgba(255,59,48,0.15)",
665
- borderRadius: SPACING.MD,
666
- padding: SPACING.MD,
667
- marginVertical: SPACING.SM - 2, // 6px
668
- },
669
- errorText: {
670
- color: "#FF6B6B",
671
- fontSize: TYPOGRAPHY.SIZE_MD,
672
- },
673
- systemContainer: {
674
- backgroundColor: "rgba(255,255,255,0.05)",
675
- borderRadius: SPACING.MD,
676
- padding: SPACING.MD,
677
- marginVertical: SPACING.SM - 2, // 6px
678
- },
679
- systemText: {
680
- color: COLORS.TEXT_TERTIARY,
681
- fontSize: TYPOGRAPHY.SIZE_MD,
682
- fontStyle: "italic",
683
- },
684
- toolLine: {
685
- flexDirection: "row",
686
- alignItems: "center",
687
- marginVertical: SPACING.XS,
688
- },
689
- toolLabel: {
690
- color: COLORS.TEXT_MUTED,
691
- fontSize: TYPOGRAPHY.SIZE_XS + 1, // 12px
692
- fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
693
- marginRight: SPACING.SM,
694
- minWidth: 36,
695
- },
696
- toolLabelFailed: {
697
- color: "rgba(255,100,100,0.6)",
698
- fontSize: TYPOGRAPHY.SIZE_XS + 1, // 12px
699
- fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
700
- marginRight: SPACING.SM,
701
- minWidth: 36,
702
- },
703
- toolValue: {
704
- color: "rgba(255,255,255,0.7)",
705
- fontSize: TYPOGRAPHY.SIZE_SM,
706
- fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
707
- flexShrink: 1,
708
- },
709
- toolValueFailed: {
710
- color: "rgba(255,100,100,0.7)",
711
- fontSize: TYPOGRAPHY.SIZE_SM,
712
- fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
713
- flexShrink: 1,
714
- },
715
- userPromptContainer: {
716
- backgroundColor: "rgba(0,122,255,0.15)",
717
- borderRadius: LAYOUT.BORDER_RADIUS_SM + 2, // 16px
718
- padding: SPACING.MD,
719
- marginVertical: SPACING.SM,
720
- alignSelf: "flex-end",
721
- maxWidth: "85%",
722
- },
723
- userPromptText: {
724
- color: COLORS.TEXT_PRIMARY,
725
- fontSize: TYPOGRAPHY.SIZE_LG,
726
- lineHeight: 20,
727
- },
728
- userImages: {
729
- flexDirection: "row",
730
- flexWrap: "wrap",
731
- gap: SPACING.SM,
732
- marginBottom: SPACING.SM,
733
- },
734
- userImageThumb: {
735
- width: 80,
736
- height: 80,
737
- borderRadius: SPACING.SM,
738
- backgroundColor: "rgba(255,255,255,0.1)",
739
- },
740
155
  scrollButton: {
741
156
  position: "absolute",
742
157
  bottom: LAYOUT.CONTENT_PADDING_H,