clawdex-mobile 2.0.1 → 3.0.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.
Files changed (71) hide show
  1. package/.github/workflows/pages.yml +41 -0
  2. package/AGENTS.md +263 -110
  3. package/README.md +1 -1
  4. package/apps/mobile/.env.example +2 -2
  5. package/apps/mobile/App.tsx +175 -14
  6. package/apps/mobile/app.json +27 -9
  7. package/apps/mobile/eas.json +14 -4
  8. package/apps/mobile/package.json +13 -13
  9. package/apps/mobile/src/api/__tests__/chatMapping.test.ts +219 -0
  10. package/apps/mobile/src/api/__tests__/client.test.ts +579 -6
  11. package/apps/mobile/src/api/__tests__/ws.test.ts +27 -0
  12. package/apps/mobile/src/api/account.ts +47 -0
  13. package/apps/mobile/src/api/chatMapping.ts +435 -18
  14. package/apps/mobile/src/api/client.ts +296 -36
  15. package/apps/mobile/src/api/rateLimits.ts +143 -0
  16. package/apps/mobile/src/api/types.ts +106 -0
  17. package/apps/mobile/src/api/ws.ts +10 -1
  18. package/apps/mobile/src/components/ChatHeader.tsx +12 -12
  19. package/apps/mobile/src/components/ChatInput.tsx +154 -88
  20. package/apps/mobile/src/components/ChatMessage.tsx +548 -93
  21. package/apps/mobile/src/components/ComposerUsageLimits.tsx +167 -0
  22. package/apps/mobile/src/components/SelectionSheet.tsx +466 -0
  23. package/apps/mobile/src/components/ToolBlock.tsx +17 -15
  24. package/apps/mobile/src/components/VoiceRecordingWaveform.tsx +181 -0
  25. package/apps/mobile/src/components/WorkspacePickerModal.tsx +572 -0
  26. package/apps/mobile/src/components/__tests__/chat-input-layout.test.ts +35 -0
  27. package/apps/mobile/src/components/__tests__/chatImageSource.test.ts +44 -0
  28. package/apps/mobile/src/components/__tests__/composerUsageLimits.test.ts +138 -0
  29. package/apps/mobile/src/components/__tests__/voiceWaveform.test.ts +31 -0
  30. package/apps/mobile/src/components/chat-input-layout.ts +59 -0
  31. package/apps/mobile/src/components/chatImageSource.ts +86 -0
  32. package/apps/mobile/src/components/usageLimitBadges.ts +109 -0
  33. package/apps/mobile/src/components/voiceWaveform.ts +46 -0
  34. package/apps/mobile/src/config.ts +9 -2
  35. package/apps/mobile/src/hooks/useVoiceRecorder.ts +8 -1
  36. package/apps/mobile/src/navigation/DrawerContent.tsx +607 -457
  37. package/apps/mobile/src/navigation/__tests__/chatThreadTree.test.ts +89 -0
  38. package/apps/mobile/src/navigation/__tests__/drawerChats.test.ts +65 -0
  39. package/apps/mobile/src/navigation/chatThreadTree.ts +191 -0
  40. package/apps/mobile/src/navigation/drawerChats.ts +9 -0
  41. package/apps/mobile/src/screens/GitScreen.tsx +2 -0
  42. package/apps/mobile/src/screens/MainScreen.tsx +4244 -1237
  43. package/apps/mobile/src/screens/OnboardingScreen.tsx +2 -0
  44. package/apps/mobile/src/screens/SettingsScreen.tsx +256 -226
  45. package/apps/mobile/src/screens/TerminalScreen.tsx +2 -5
  46. package/apps/mobile/src/screens/__tests__/agentThreadDisplay.test.ts +80 -0
  47. package/apps/mobile/src/screens/__tests__/agentThreads.test.ts +170 -0
  48. package/apps/mobile/src/screens/__tests__/planCardState.test.ts +88 -0
  49. package/apps/mobile/src/screens/__tests__/subAgentTranscript.test.ts +102 -0
  50. package/apps/mobile/src/screens/__tests__/transcriptMessages.test.ts +97 -0
  51. package/apps/mobile/src/screens/agentThreadDisplay.ts +261 -0
  52. package/apps/mobile/src/screens/agentThreads.ts +167 -0
  53. package/apps/mobile/src/screens/planCardState.ts +40 -0
  54. package/apps/mobile/src/screens/subAgentTranscript.ts +149 -0
  55. package/apps/mobile/src/screens/transcriptMessages.ts +102 -0
  56. package/apps/mobile/src/theme.ts +6 -12
  57. package/docs/codex-app-server-cli-gap-tracker.md +14 -5
  58. package/docs/privacy-policy.md +54 -0
  59. package/docs/setup-and-operations.md +4 -3
  60. package/docs/terms-of-service.md +33 -0
  61. package/package.json +3 -3
  62. package/services/mac-bridge/package.json +6 -6
  63. package/services/rust-bridge/Cargo.lock +56 -47
  64. package/services/rust-bridge/Cargo.toml +1 -1
  65. package/services/rust-bridge/package.json +1 -1
  66. package/services/rust-bridge/src/main.rs +507 -9
  67. package/site/index.html +54 -0
  68. package/site/privacy/index.html +80 -0
  69. package/site/styles.css +135 -0
  70. package/site/support/index.html +51 -0
  71. package/site/terms/index.html +68 -0
@@ -1,14 +1,26 @@
1
1
  import { Ionicons } from '@expo/vector-icons';
2
- import { memo } from 'react';
3
- import { Image, Linking, Platform, StyleSheet, Text, View } from 'react-native';
2
+ import { memo, useMemo, useState, type ReactElement } from 'react';
3
+ import {
4
+ Image,
5
+ Pressable,
6
+ type ImageSourcePropType,
7
+ Linking,
8
+ Platform,
9
+ StyleSheet,
10
+ Text,
11
+ type TextProps,
12
+ View,
13
+ } from 'react-native';
4
14
  import Markdown, { type RenderRules } from 'react-native-markdown-display';
5
- import Animated, { FadeInUp, Layout } from 'react-native-reanimated';
6
15
 
7
16
  import type { ChatMessage as ApiChatMessage } from '../api/types';
8
17
  import { colors, radius, spacing, typography } from '../theme';
18
+ import { toMarkdownImageSource } from './chatImageSource';
9
19
 
10
20
  interface ChatMessageProps {
11
21
  message: ApiChatMessage;
22
+ bridgeUrl?: string | null;
23
+ bridgeToken?: string | null;
12
24
  }
13
25
 
14
26
  interface TimelineEntry {
@@ -16,39 +28,151 @@ interface TimelineEntry {
16
28
  details: string[];
17
29
  }
18
30
 
19
- function ChatMessageComponent({ message }: ChatMessageProps) {
31
+ type UserMessageBlock =
32
+ | { kind: 'text'; value: string }
33
+ | { kind: 'file'; value: string }
34
+ | { kind: 'image'; source: ImageSourcePropType; accessibilityLabel?: string };
35
+
36
+ function ChatMessageComponent({ message, bridgeUrl = null, bridgeToken = null }: ChatMessageProps) {
20
37
  const isUser = message.role === 'user';
38
+ const markdownRules = useMemo(
39
+ () => createMarkdownRules(bridgeUrl, bridgeToken),
40
+ [bridgeToken, bridgeUrl]
41
+ );
42
+ const [expandedTimelineEntries, setExpandedTimelineEntries] = useState<
43
+ Record<string, boolean>
44
+ >({});
45
+ const userBlocks = useMemo(
46
+ () =>
47
+ isUser ? parseUserMessageBlocks(message.content, bridgeUrl, bridgeToken) : [],
48
+ [bridgeToken, bridgeUrl, isUser, message.content]
49
+ );
21
50
 
22
- if (isUser) {
23
- return (
24
- <Animated.View
25
- entering={FadeInUp.duration(300)}
26
- layout={Layout.springify()}
27
- style={[styles.messageWrapper, styles.messageWrapperUser]}
28
- >
29
- <View style={styles.userBubble}>
30
- <Text style={styles.userMessageText}>{message.content}</Text>
51
+ const renderedMessage = isUser ? (
52
+ <View style={[styles.messageWrapper, styles.messageWrapperUser]}>
53
+ <View style={[styles.userBubble, userBlocks.length > 1 && styles.userBubbleWithAttachments]}>
54
+ <View style={styles.userBubbleContent}>
55
+ {userBlocks.map((block, index) => {
56
+ if (block.kind === 'image') {
57
+ return (
58
+ <MarkdownImage
59
+ key={`${message.id}-image-${String(index)}`}
60
+ source={block.source}
61
+ accessibilityLabel={block.accessibilityLabel}
62
+ />
63
+ );
64
+ }
65
+
66
+ if (block.kind === 'file') {
67
+ return (
68
+ <View key={`${message.id}-file-${String(index)}`} style={styles.userFileChip}>
69
+ <Ionicons name="document-text-outline" size={12} color={colors.textMuted} />
70
+ <Text style={styles.userFileChipText} numberOfLines={1}>
71
+ {block.value}
72
+ </Text>
73
+ </View>
74
+ );
75
+ }
76
+
77
+ return (
78
+ <SelectableMessageText
79
+ key={`${message.id}-text-${String(index)}`}
80
+ style={styles.userMessageText}
81
+ >
82
+ {block.value}
83
+ </SelectableMessageText>
84
+ );
85
+ })}
31
86
  </View>
32
- </Animated.View>
33
- );
87
+ </View>
88
+ </View>
89
+ ) : null;
90
+
91
+ if (renderedMessage) {
92
+ return renderedMessage;
34
93
  }
35
94
 
36
95
  const timelineEntries =
37
96
  message.role === 'system' ? parseTimelineEntries(message.content) : null;
97
+ if (message.role === 'system' && message.systemKind === 'subAgent') {
98
+ const subAgentEntries =
99
+ timelineEntries && timelineEntries.length > 0
100
+ ? timelineEntries
101
+ : [{ title: message.content, details: [] }];
102
+
103
+ return (
104
+ <View style={[styles.messageWrapper, styles.messageWrapperAssistant]}>
105
+ <View style={styles.subAgentCardStack}>
106
+ {subAgentEntries.map((entry, index) => {
107
+ const visual = toSubAgentVisual(entry.title);
108
+ return (
109
+ <View
110
+ key={`${message.id}-subagent-${String(index)}`}
111
+ style={[
112
+ styles.subAgentCard,
113
+ visual.isError && styles.subAgentCardError,
114
+ ]}
115
+ >
116
+ <View style={styles.subAgentHeader}>
117
+ <Ionicons
118
+ name={visual.icon}
119
+ size={14}
120
+ color={visual.isError ? colors.statusError : '#F5A524'}
121
+ />
122
+ <Text style={styles.subAgentTitle}>{entry.title}</Text>
123
+ </View>
124
+ {entry.details.length > 0 ? (
125
+ <View style={styles.subAgentDetailWrap}>
126
+ {entry.details.map((line, lineIndex) => (
127
+ <SelectableMessageText
128
+ key={`${message.id}-subagent-${String(index)}-line-${String(lineIndex)}`}
129
+ style={styles.subAgentDetailLine}
130
+ >
131
+ {line}
132
+ </SelectableMessageText>
133
+ ))}
134
+ </View>
135
+ ) : null}
136
+ </View>
137
+ );
138
+ })}
139
+ </View>
140
+ </View>
141
+ );
142
+ }
38
143
  if (timelineEntries && timelineEntries.length > 0) {
39
144
  return (
40
- <Animated.View
41
- entering={FadeInUp.duration(300).delay(50)}
42
- layout={Layout.springify()}
43
- style={[styles.messageWrapper, styles.messageWrapperAssistant]}
44
- >
145
+ <View style={[styles.messageWrapper, styles.messageWrapperAssistant]}>
45
146
  <View style={styles.timelineCardStack}>
46
147
  {timelineEntries.map((entry, index) => {
47
148
  const visual = toTimelineVisual(entry.title);
149
+ const timelineKey = `${message.id}-timeline-${String(index)}`;
150
+ const hasDetails = entry.details.length > 0;
151
+ const expanded = expandedTimelineEntries[timelineKey] === true;
152
+ const toggleLabel = expanded
153
+ ? 'Tap to hide output'
154
+ : entry.details.length <= 1
155
+ ? 'Tap to show output'
156
+ : `Tap to show ${String(entry.details.length)} lines`;
48
157
  return (
49
- <View
158
+ <Pressable
50
159
  key={`${message.id}-timeline-${String(index)}`}
51
- style={[styles.timelineCard, visual.isError && styles.timelineCardError]}
160
+ disabled={!hasDetails}
161
+ onPress={() => {
162
+ if (!hasDetails) {
163
+ return;
164
+ }
165
+ setExpandedTimelineEntries((previous) => ({
166
+ ...previous,
167
+ [timelineKey]: !previous[timelineKey],
168
+ }));
169
+ }}
170
+ style={({ pressed }) => [
171
+ styles.timelineCard,
172
+ visual.isError && styles.timelineCardError,
173
+ hasDetails && styles.timelineCardInteractive,
174
+ pressed && hasDetails && styles.timelineCardPressed,
175
+ ]}
52
176
  >
53
177
  <View style={styles.timelineHeader}>
54
178
  <Ionicons
@@ -61,40 +185,47 @@ function ChatMessageComponent({ message }: ChatMessageProps) {
61
185
  styles.timelineTitle,
62
186
  visual.useMonospaceTitle && styles.timelineTitleMono,
63
187
  ]}
188
+ numberOfLines={expanded ? 3 : 1}
64
189
  >
65
190
  {entry.title}
66
191
  </Text>
192
+ {hasDetails ? (
193
+ <Ionicons
194
+ name={expanded ? 'chevron-up' : 'chevron-down'}
195
+ size={14}
196
+ color={colors.textMuted}
197
+ />
198
+ ) : null}
67
199
  </View>
68
- {entry.details.length > 0 ? (
200
+ {hasDetails ? (
201
+ <Text style={styles.timelineToggleText}>{toggleLabel}</Text>
202
+ ) : null}
203
+ {expanded && entry.details.length > 0 ? (
69
204
  <View style={styles.timelineDetailWrap}>
70
205
  {entry.details.map((line, lineIndex) => (
71
- <Text
206
+ <SelectableMessageText
72
207
  key={`${message.id}-timeline-${String(index)}-line-${String(lineIndex)}`}
73
208
  style={styles.timelineDetailLine}
74
209
  >
75
210
  {line}
76
- </Text>
211
+ </SelectableMessageText>
77
212
  ))}
78
213
  </View>
79
214
  ) : null}
80
- </View>
215
+ </Pressable>
81
216
  );
82
217
  })}
83
218
  </View>
84
- </Animated.View>
219
+ </View>
85
220
  );
86
221
  }
87
222
 
88
223
  return (
89
- <Animated.View
90
- entering={FadeInUp.duration(300).delay(50)}
91
- layout={Layout.springify()}
92
- style={[styles.messageWrapper, styles.messageWrapperAssistant]}
93
- >
224
+ <View style={[styles.messageWrapper, styles.messageWrapperAssistant]}>
94
225
  <Markdown style={markdownStyles} rules={markdownRules}>
95
226
  {message.content || '\u258D'}
96
227
  </Markdown>
97
- </Animated.View>
228
+ </View>
98
229
  );
99
230
  }
100
231
 
@@ -113,7 +244,10 @@ function areChatMessagePropsEqual(
113
244
  previous.id === next.id &&
114
245
  previous.role === next.role &&
115
246
  previous.content === next.content &&
116
- previous.createdAt === next.createdAt
247
+ previous.createdAt === next.createdAt &&
248
+ previous.systemKind === next.systemKind &&
249
+ prevProps.bridgeUrl === nextProps.bridgeUrl &&
250
+ prevProps.bridgeToken === nextProps.bridgeToken
117
251
  );
118
252
  }
119
253
 
@@ -186,71 +320,133 @@ const markdownStyles = StyleSheet.create({
186
320
  },
187
321
  });
188
322
 
189
- const markdownRules: RenderRules = {
190
- link: (node, children, _parent, styles, onLinkPress) => {
191
- const href = readMarkdownAttr(node.attributes.href);
192
- if (!href) {
323
+ function createMarkdownRules(
324
+ bridgeUrl: string | null,
325
+ bridgeToken: string | null
326
+ ): RenderRules {
327
+ return {
328
+ text: (node, _children, _parent, styles, inheritedStyles = {}) => (
329
+ <SelectableMessageText key={node.key} style={[inheritedStyles, styles.text]}>
330
+ {node.content}
331
+ </SelectableMessageText>
332
+ ),
333
+ textgroup: (node, children, _parent, styles) => (
334
+ <SelectableMessageText key={node.key} style={styles.textgroup}>
335
+ {children}
336
+ </SelectableMessageText>
337
+ ),
338
+ strong: (node, children, _parent, styles) => (
339
+ <SelectableMessageText key={node.key} style={styles.strong}>
340
+ {children}
341
+ </SelectableMessageText>
342
+ ),
343
+ em: (node, children, _parent, styles) => (
344
+ <SelectableMessageText key={node.key} style={styles.em}>
345
+ {children}
346
+ </SelectableMessageText>
347
+ ),
348
+ s: (node, children, _parent, styles) => (
349
+ <SelectableMessageText key={node.key} style={styles.s}>
350
+ {children}
351
+ </SelectableMessageText>
352
+ ),
353
+ code_inline: (node, _children, _parent, styles, inheritedStyles = {}) => (
354
+ <SelectableMessageText key={node.key} style={[inheritedStyles, styles.code_inline]}>
355
+ {node.content}
356
+ </SelectableMessageText>
357
+ ),
358
+ code_block: (node, _children, _parent, styles, inheritedStyles = {}) => {
359
+ const content =
360
+ typeof node.content === 'string' && node.content.charAt(node.content.length - 1) === '\n'
361
+ ? node.content.substring(0, node.content.length - 1)
362
+ : node.content;
193
363
  return (
194
- <Text key={node.key} style={styles.link}>
195
- {children}
196
- </Text>
364
+ <SelectableMessageText key={node.key} style={[inheritedStyles, styles.code_block]}>
365
+ {content}
366
+ </SelectableMessageText>
197
367
  );
198
- }
199
-
200
- const localFileReference = toLocalFileReferenceLabel(href);
201
- if (localFileReference) {
368
+ },
369
+ fence: (node, _children, _parent, styles, inheritedStyles = {}) => {
370
+ const content =
371
+ typeof node.content === 'string' && node.content.charAt(node.content.length - 1) === '\n'
372
+ ? node.content.substring(0, node.content.length - 1)
373
+ : node.content;
202
374
  return (
203
- <Text key={node.key} style={styles.code_inline}>
204
- {localFileReference}
205
- </Text>
375
+ <SelectableMessageText key={node.key} style={[inheritedStyles, styles.fence]}>
376
+ {content}
377
+ </SelectableMessageText>
206
378
  );
207
- }
208
-
209
- return (
210
- <Text
211
- key={node.key}
212
- style={styles.link}
213
- onPress={() => openMarkdownLink(href, onLinkPress)}
214
- >
379
+ },
380
+ hardbreak: (node, _children, _parent, styles) => (
381
+ <SelectableMessageText key={node.key} style={styles.hardbreak}>
382
+ {'\n'}
383
+ </SelectableMessageText>
384
+ ),
385
+ softbreak: (node, _children, _parent, styles) => (
386
+ <SelectableMessageText key={node.key} style={styles.softbreak}>
387
+ {'\n'}
388
+ </SelectableMessageText>
389
+ ),
390
+ inline: (node, children, _parent, styles) => (
391
+ <SelectableMessageText key={node.key} style={styles.inline}>
215
392
  {children}
216
- </Text>
217
- );
218
- },
219
- image: (
220
- node,
221
- _children,
222
- _parent,
223
- _styles,
224
- allowedImageHandlers = [],
225
- defaultImageHandler = '',
226
- ) => {
227
- const src = readMarkdownAttr(node.attributes.src);
228
- if (!src) {
229
- return null;
230
- }
393
+ </SelectableMessageText>
394
+ ),
395
+ span: (node, children, _parent, styles) => (
396
+ <SelectableMessageText key={node.key} style={styles.span}>
397
+ {children}
398
+ </SelectableMessageText>
399
+ ),
400
+ link: (node, children, _parent, styles, onLinkPress) => {
401
+ const href = readMarkdownAttr(node.attributes.href);
402
+ if (!href) {
403
+ return (
404
+ <SelectableMessageText key={node.key} style={styles.link}>
405
+ {children}
406
+ </SelectableMessageText>
407
+ );
408
+ }
231
409
 
232
- const isAllowed = allowedImageHandlers.some((handler) =>
233
- src.toLowerCase().startsWith(handler.toLowerCase())
234
- );
235
- if (!isAllowed && defaultImageHandler === null) {
236
- return null;
237
- }
410
+ const localFileReference = toLocalFileReferenceLabel(href);
411
+ if (localFileReference) {
412
+ return (
413
+ <SelectableMessageText key={node.key} style={styles.code_inline}>
414
+ {localFileReference}
415
+ </SelectableMessageText>
416
+ );
417
+ }
238
418
 
239
- const uri = isAllowed ? src : `${defaultImageHandler}${src}`;
240
- const alt = readMarkdownAttr(node.attributes.alt);
419
+ return (
420
+ <SelectableMessageText
421
+ key={node.key}
422
+ style={styles.link}
423
+ onPress={() => openMarkdownLink(href, onLinkPress)}
424
+ >
425
+ {children}
426
+ </SelectableMessageText>
427
+ );
428
+ },
429
+ image: (node) => {
430
+ const src = readMarkdownAttr(node.attributes.src);
431
+ if (!src) {
432
+ return null;
433
+ }
434
+ const source = toMarkdownImageSource(src, bridgeUrl, bridgeToken);
435
+ if (!source) {
436
+ return null;
437
+ }
438
+ const alt = readMarkdownAttr(node.attributes.alt);
241
439
 
242
- return (
243
- <Image
244
- key={node.key}
245
- source={{ uri }}
246
- style={styles.markdownImage}
247
- resizeMode="contain"
248
- accessible={Boolean(alt)}
249
- accessibilityLabel={alt ?? undefined}
250
- />
251
- );
252
- },
253
- };
440
+ return (
441
+ <MarkdownImage
442
+ key={node.key}
443
+ source={source}
444
+ accessibilityLabel={alt ?? undefined}
445
+ />
446
+ );
447
+ },
448
+ };
449
+ }
254
450
 
255
451
  const styles = StyleSheet.create({
256
452
  messageWrapper: {
@@ -271,23 +467,88 @@ const styles = StyleSheet.create({
271
467
  paddingHorizontal: spacing.lg,
272
468
  paddingVertical: spacing.md,
273
469
  },
470
+ userBubbleWithAttachments: {
471
+ minWidth: 196,
472
+ },
473
+ userBubbleContent: {
474
+ gap: spacing.sm,
475
+ },
274
476
  userMessageText: {
275
477
  fontFamily: monoFont,
276
478
  fontSize: 14,
277
479
  color: colors.textPrimary,
278
480
  lineHeight: 20,
279
481
  },
482
+ userFileChip: {
483
+ flexDirection: 'row',
484
+ alignItems: 'center',
485
+ alignSelf: 'flex-start',
486
+ gap: spacing.xs,
487
+ borderRadius: radius.sm,
488
+ borderWidth: StyleSheet.hairlineWidth,
489
+ borderColor: colors.userBubbleBorder,
490
+ backgroundColor: colors.bgMain,
491
+ paddingHorizontal: spacing.sm,
492
+ paddingVertical: spacing.xs,
493
+ maxWidth: '100%',
494
+ },
495
+ userFileChipText: {
496
+ fontFamily: monoFont,
497
+ fontSize: 12,
498
+ lineHeight: 16,
499
+ color: colors.textMuted,
500
+ flexShrink: 1,
501
+ },
280
502
  markdownImage: {
281
503
  width: '100%',
282
- minHeight: 120,
283
- maxHeight: 260,
284
504
  borderRadius: radius.sm,
285
505
  marginVertical: spacing.sm,
286
506
  backgroundColor: colors.bgInput,
287
507
  },
508
+ markdownImageFallback: {
509
+ minHeight: 120,
510
+ maxHeight: 260,
511
+ },
288
512
  timelineCardStack: {
289
513
  gap: spacing.sm,
290
514
  },
515
+ subAgentCardStack: {
516
+ gap: spacing.xs + 2,
517
+ },
518
+ subAgentCard: {
519
+ borderRadius: radius.md,
520
+ borderWidth: StyleSheet.hairlineWidth,
521
+ borderColor: 'rgba(245, 165, 36, 0.35)',
522
+ backgroundColor: 'rgba(245, 165, 36, 0.08)',
523
+ paddingHorizontal: spacing.md,
524
+ paddingVertical: spacing.sm + 1,
525
+ },
526
+ subAgentCardError: {
527
+ borderColor: colors.statusError,
528
+ backgroundColor: colors.errorBg,
529
+ },
530
+ subAgentHeader: {
531
+ flexDirection: 'row',
532
+ alignItems: 'center',
533
+ gap: spacing.sm,
534
+ },
535
+ subAgentTitle: {
536
+ ...typography.body,
537
+ color: colors.textPrimary,
538
+ flex: 1,
539
+ fontSize: 13,
540
+ lineHeight: 18,
541
+ },
542
+ subAgentDetailWrap: {
543
+ marginTop: spacing.xs,
544
+ paddingLeft: spacing.lg + 2,
545
+ gap: 2,
546
+ },
547
+ subAgentDetailLine: {
548
+ ...typography.caption,
549
+ color: colors.textMuted,
550
+ lineHeight: 16,
551
+ },
291
552
  timelineCard: {
292
553
  borderRadius: radius.md,
293
554
  borderWidth: StyleSheet.hairlineWidth,
@@ -300,6 +561,12 @@ const styles = StyleSheet.create({
300
561
  borderColor: colors.statusError,
301
562
  backgroundColor: colors.errorBg,
302
563
  },
564
+ timelineCardInteractive: {
565
+ overflow: 'hidden',
566
+ },
567
+ timelineCardPressed: {
568
+ opacity: 0.82,
569
+ },
303
570
  timelineHeader: {
304
571
  flexDirection: 'row',
305
572
  alignItems: 'center',
@@ -324,6 +591,11 @@ const styles = StyleSheet.create({
324
591
  paddingTop: spacing.xs,
325
592
  gap: 2,
326
593
  },
594
+ timelineToggleText: {
595
+ ...typography.caption,
596
+ color: colors.textMuted,
597
+ marginTop: spacing.xs,
598
+ },
327
599
  timelineDetailLine: {
328
600
  fontFamily: monoFont,
329
601
  fontSize: 11,
@@ -336,6 +608,147 @@ function readMarkdownAttr(value: unknown): string | null {
336
608
  return typeof value === 'string' && value.trim().length > 0 ? value : null;
337
609
  }
338
610
 
611
+ function SelectableMessageText({ children, ...props }: TextProps): ReactElement {
612
+ return (
613
+ <Text selectable={props.selectable ?? !props.onPress} {...props}>
614
+ {children}
615
+ </Text>
616
+ );
617
+ }
618
+
619
+ function MarkdownImage({
620
+ source,
621
+ accessibilityLabel,
622
+ }: {
623
+ source: ImageSourcePropType;
624
+ accessibilityLabel?: string;
625
+ }): ReactElement {
626
+ const [aspectRatio, setAspectRatio] = useState<number | null>(null);
627
+
628
+ return (
629
+ <Image
630
+ source={source}
631
+ style={[
632
+ styles.markdownImage,
633
+ aspectRatio ? { aspectRatio } : styles.markdownImageFallback,
634
+ ]}
635
+ resizeMode="contain"
636
+ accessible={Boolean(accessibilityLabel)}
637
+ accessibilityLabel={accessibilityLabel}
638
+ onLoad={(event) => {
639
+ const width = event.nativeEvent.source?.width;
640
+ const height = event.nativeEvent.source?.height;
641
+ if (
642
+ typeof width !== 'number' ||
643
+ typeof height !== 'number' ||
644
+ !Number.isFinite(width) ||
645
+ !Number.isFinite(height) ||
646
+ width <= 0 ||
647
+ height <= 0
648
+ ) {
649
+ return;
650
+ }
651
+
652
+ const nextAspectRatio = width / height;
653
+ setAspectRatio((previousAspectRatio) =>
654
+ previousAspectRatio === nextAspectRatio ? previousAspectRatio : nextAspectRatio
655
+ );
656
+ }}
657
+ />
658
+ );
659
+ }
660
+
661
+ function parseUserMessageBlocks(
662
+ content: string,
663
+ bridgeUrl: string | null,
664
+ bridgeToken: string | null
665
+ ): UserMessageBlock[] {
666
+ const blocks: UserMessageBlock[] = [];
667
+ const pendingTextLines: string[] = [];
668
+
669
+ const flushTextBlock = () => {
670
+ if (pendingTextLines.length === 0) {
671
+ return;
672
+ }
673
+
674
+ const value = pendingTextLines.join('\n');
675
+ pendingTextLines.length = 0;
676
+ if (!value.trim()) {
677
+ return;
678
+ }
679
+
680
+ blocks.push({
681
+ kind: 'text',
682
+ value,
683
+ });
684
+ };
685
+
686
+ for (const line of content.split('\n')) {
687
+ const localImageMatch = line.match(/^\[local image:\s*(.+?)\]$/i);
688
+ if (localImageMatch) {
689
+ const source = toMarkdownImageSource(localImageMatch[1], bridgeUrl, bridgeToken);
690
+ if (source) {
691
+ flushTextBlock();
692
+ blocks.push({
693
+ kind: 'image',
694
+ source,
695
+ accessibilityLabel: toPathBasename(localImageMatch[1]),
696
+ });
697
+ continue;
698
+ }
699
+ }
700
+
701
+ const remoteImageMatch = line.match(/^\[image:\s*(.+?)\]$/i);
702
+ if (remoteImageMatch) {
703
+ const source = toMarkdownImageSource(remoteImageMatch[1], bridgeUrl, bridgeToken);
704
+ if (source) {
705
+ flushTextBlock();
706
+ blocks.push({
707
+ kind: 'image',
708
+ source,
709
+ accessibilityLabel: toPathBasename(remoteImageMatch[1]),
710
+ });
711
+ continue;
712
+ }
713
+ }
714
+
715
+ const fileMatch = line.match(/^\[file:\s*(.+?)\]$/i);
716
+ if (fileMatch) {
717
+ flushTextBlock();
718
+ blocks.push({
719
+ kind: 'file',
720
+ value: toLocalFileReferenceLabel(fileMatch[1]) ?? toPathBasename(fileMatch[1]),
721
+ });
722
+ continue;
723
+ }
724
+
725
+ pendingTextLines.push(line);
726
+ }
727
+
728
+ flushTextBlock();
729
+
730
+ if (blocks.length === 0) {
731
+ return [
732
+ {
733
+ kind: 'text',
734
+ value: content,
735
+ },
736
+ ];
737
+ }
738
+
739
+ return blocks;
740
+ }
741
+
742
+ function toPathBasename(path: string): string {
743
+ const normalizedPath = path.trim().replace(/\\/g, '/');
744
+ if (!normalizedPath) {
745
+ return 'image';
746
+ }
747
+
748
+ const basename = normalizedPath.split('/').filter(Boolean).pop();
749
+ return basename && basename.length > 0 ? basename : normalizedPath;
750
+ }
751
+
339
752
  function openMarkdownLink(
340
753
  href: string,
341
754
  onLinkPress?: (url: string) => boolean
@@ -502,3 +915,45 @@ function toTimelineVisual(title: string): {
502
915
  isError: false,
503
916
  };
504
917
  }
918
+
919
+ function toSubAgentVisual(title: string): {
920
+ icon: keyof typeof Ionicons.glyphMap;
921
+ isError: boolean;
922
+ } {
923
+ const normalized = title.toLowerCase();
924
+ const isError =
925
+ normalized.includes('failed') || normalized.includes('error') || normalized.includes('aborted');
926
+
927
+ if (isError) {
928
+ return {
929
+ icon: 'alert-circle-outline',
930
+ isError: true,
931
+ };
932
+ }
933
+
934
+ if (normalized.includes('waiting')) {
935
+ return {
936
+ icon: 'pause-circle-outline',
937
+ isError: false,
938
+ };
939
+ }
940
+
941
+ if (normalized.includes('closed')) {
942
+ return {
943
+ icon: 'checkmark-circle-outline',
944
+ isError: false,
945
+ };
946
+ }
947
+
948
+ if (normalized.includes('spawn')) {
949
+ return {
950
+ icon: 'sparkles-outline',
951
+ isError: false,
952
+ };
953
+ }
954
+
955
+ return {
956
+ icon: 'git-branch-outline',
957
+ isError: false,
958
+ };
959
+ }