clawdex-mobile 1.3.1 → 2.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 (48) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/npm-release.yml +18 -0
  3. package/AGENTS.md +3 -3
  4. package/README.md +104 -542
  5. package/apps/mobile/.env.example +1 -2
  6. package/apps/mobile/App.tsx +261 -68
  7. package/apps/mobile/app.json +31 -5
  8. package/apps/mobile/assets/brand/splash-icon-white.png +0 -0
  9. package/apps/mobile/eas.json +30 -0
  10. package/apps/mobile/package.json +22 -21
  11. package/apps/mobile/plugins/withAndroidCleartextTraffic.js +14 -0
  12. package/apps/mobile/src/api/__tests__/ws.test.ts +44 -6
  13. package/apps/mobile/src/api/chatMapping.ts +48 -8
  14. package/apps/mobile/src/api/client.ts +6 -0
  15. package/apps/mobile/src/api/types.ts +11 -0
  16. package/apps/mobile/src/api/ws.ts +52 -10
  17. package/apps/mobile/src/bridgeUrl.ts +105 -0
  18. package/apps/mobile/src/components/ActivityBar.tsx +32 -13
  19. package/apps/mobile/src/components/ChatHeader.tsx +3 -2
  20. package/apps/mobile/src/components/ChatInput.tsx +246 -91
  21. package/apps/mobile/src/components/ChatMessage.tsx +108 -4
  22. package/apps/mobile/src/config.ts +11 -29
  23. package/apps/mobile/src/hooks/useVoiceRecorder.ts +264 -0
  24. package/apps/mobile/src/navigation/DrawerContent.tsx +18 -8
  25. package/apps/mobile/src/screens/GitScreen.tsx +1 -1
  26. package/apps/mobile/src/screens/MainScreen.tsx +906 -268
  27. package/apps/mobile/src/screens/OnboardingScreen.tsx +1132 -0
  28. package/apps/mobile/src/screens/PrivacyScreen.tsx +1 -1
  29. package/apps/mobile/src/screens/SettingsScreen.tsx +65 -1
  30. package/apps/mobile/src/screens/TerminalScreen.tsx +1 -1
  31. package/apps/mobile/src/screens/TermsScreen.tsx +1 -1
  32. package/docs/app-review-notes.md +7 -2
  33. package/docs/eas-builds.md +91 -0
  34. package/docs/realtime-streaming-limitations.md +84 -0
  35. package/docs/setup-and-operations.md +239 -0
  36. package/docs/troubleshooting.md +121 -0
  37. package/docs/voice-transcription.md +87 -0
  38. package/package.json +8 -16
  39. package/scripts/setup-secure-dev.sh +122 -8
  40. package/scripts/setup-wizard.sh +342 -122
  41. package/scripts/start-bridge-secure.sh +7 -1
  42. package/scripts/sync-versions.js +63 -0
  43. package/services/rust-bridge/.env.example +1 -1
  44. package/services/rust-bridge/Cargo.lock +1104 -23
  45. package/services/rust-bridge/Cargo.toml +3 -1
  46. package/services/rust-bridge/package.json +1 -1
  47. package/services/rust-bridge/src/main.rs +587 -12
  48. package/apps/mobile/metro.config.js +0 -3
@@ -15,6 +15,7 @@ import {
15
15
  ActionSheetIOS,
16
16
  ActivityIndicator,
17
17
  Alert,
18
+ FlatList,
18
19
  Keyboard,
19
20
  KeyboardAvoidingView,
20
21
  Modal,
@@ -24,9 +25,13 @@ import {
24
25
  StyleSheet,
25
26
  Text,
26
27
  TextInput,
28
+ type ListRenderItem,
29
+ type NativeScrollEvent,
30
+ type NativeSyntheticEvent,
27
31
  useWindowDimensions,
28
32
  View,
29
33
  } from 'react-native';
34
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
30
35
 
31
36
  import type { HostBridgeApiClient } from '../api/client';
32
37
  import type {
@@ -57,6 +62,7 @@ import { BrandMark } from '../components/BrandMark';
57
62
  import { ToolBlock } from '../components/ToolBlock';
58
63
  import { TypingIndicator } from '../components/TypingIndicator';
59
64
  import { env } from '../config';
65
+ import { useVoiceRecorder } from '../hooks/useVoiceRecorder';
60
66
  import { colors, spacing, typography } from '../theme';
61
67
 
62
68
  export interface MainScreenHandle {
@@ -116,6 +122,13 @@ interface ComposerAttachmentChip {
116
122
  label: string;
117
123
  }
118
124
 
125
+ interface QueuedChatMessage {
126
+ content: string;
127
+ mentions: MentionInput[];
128
+ localImages: LocalImageInput[];
129
+ collaborationMode: CollaborationMode;
130
+ }
131
+
119
132
  interface SlashCommandDefinition {
120
133
  name: string;
121
134
  summary: string;
@@ -128,25 +141,29 @@ interface SlashCommandDefinition {
128
141
  const MAX_ACTIVE_COMMANDS = 16;
129
142
  const MAX_VISIBLE_TOOL_BLOCKS = 3;
130
143
  const RUN_WATCHDOG_MS = 60_000;
144
+ const CHAT_OPEN_REVEAL_DELAY_MS = 260;
145
+ const LARGE_CHAT_OPEN_REVEAL_DELAY_MS = 2_000;
146
+ const LARGE_CHAT_MESSAGE_COUNT_THRESHOLD = 120;
131
147
  const LIKELY_RUNNING_RECENT_UPDATE_MS = 30_000;
148
+ const UNANSWERED_USER_RUNNING_TTL_MS = 90_000;
132
149
  const ACTIVE_CHAT_SYNC_INTERVAL_MS = 2_000;
133
150
  const IDLE_CHAT_SYNC_INTERVAL_MS = 2_500;
134
151
  const CHAT_MODEL_PREFERENCES_FILE = 'chat-model-preferences.json';
135
152
  const CHAT_MODEL_PREFERENCES_VERSION = 1;
136
153
  const INLINE_OPTION_LINE_PATTERN =
137
154
  /^(?:[-*+]\s*)?(?:\d{1,2}\s*[.):-]|\(\d{1,2}\)\s*[.):-]?|\[\d{1,2}\]\s*|[A-Ca-c]\s*[.):-]|\([A-Ca-c]\)\s*[.):-]?|option\s+\d{1,2}\s*[.):-]?)\s*(.+)$/i;
138
- const INLINE_CHOICE_CUE_PHRASES = [
139
- 'choose',
140
- 'select',
141
- 'pick',
142
- 'which',
143
- 'what',
144
- 'prefer',
145
- 'option',
146
- 'let me know',
147
- 'would you like',
148
- 'should i',
149
- 'confirm',
155
+ const INLINE_CHOICE_CUE_PATTERNS = [
156
+ /\bchoose\b/i,
157
+ /\bselect\b/i,
158
+ /\bpick\b/i,
159
+ /\bwould you like\b/i,
160
+ /\bshould i\b/i,
161
+ /\bprefer\b/i,
162
+ /\bconfirm\b/i,
163
+ /\b(?:reply|respond)\s+with\b/i,
164
+ /\blet me know\b.*\b(which|what|option|one)\b/i,
165
+ /\bwhich\b.*\b(option|one)\b/i,
166
+ /\bwhat\b.*\b(option|one)\b/i,
150
167
  ];
151
168
  const CODEX_RUN_HEARTBEAT_EVENT_TYPES = new Set([
152
169
  'taskstarted',
@@ -173,6 +190,25 @@ const CODEX_RUN_FAILURE_EVENT_TYPES = new Set([
173
190
  'taskfailed',
174
191
  'turnfailed',
175
192
  ]);
193
+ const EXTERNAL_RUNNING_STATUS_HINTS = new Set([
194
+ 'running',
195
+ 'inprogress',
196
+ 'active',
197
+ 'queued',
198
+ 'pending',
199
+ ]);
200
+ const EXTERNAL_ERROR_STATUS_HINTS = new Set([
201
+ 'failed',
202
+ 'error',
203
+ 'interrupted',
204
+ 'aborted',
205
+ ]);
206
+ const EXTERNAL_COMPLETE_STATUS_HINTS = new Set([
207
+ 'complete',
208
+ 'completed',
209
+ 'success',
210
+ 'succeeded',
211
+ ]);
176
212
 
177
213
  interface ChatModelPreference {
178
214
  modelId: string | null;
@@ -418,15 +454,72 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
418
454
  const [selectedEffort, setSelectedEffort] = useState<ReasoningEffort | null>(null);
419
455
  const [selectedCollaborationMode, setSelectedCollaborationMode] =
420
456
  useState<CollaborationMode>('default');
457
+ const [keyboardVisible, setKeyboardVisible] = useState(false);
458
+ const [queuedMessages, setQueuedMessages] = useState<QueuedChatMessage[]>([]);
459
+ const [queueDispatching, setQueueDispatching] = useState(false);
460
+ const [queuePaused, setQueuePaused] = useState(false);
421
461
  const [effortModalVisible, setEffortModalVisible] = useState(false);
422
462
  const [effortPickerModelId, setEffortPickerModelId] = useState<string | null>(null);
423
463
  const [activity, setActivity] = useState<ActivityState>({
424
464
  tone: 'idle',
425
465
  title: 'Ready',
426
466
  });
427
- const scrollRef = useRef<ScrollView>(null);
467
+ const [composerHeight, setComposerHeight] = useState(spacing.xxl * 4);
468
+ const safeAreaInsets = useSafeAreaInsets();
469
+ const scrollRef = useRef<FlatList<ChatTranscriptMessage>>(null);
470
+ const scrollRetryTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
428
471
  const loadChatRequestRef = useRef(0);
429
472
 
473
+ const voiceRecorder = useVoiceRecorder({
474
+ transcribe: (dataBase64, prompt, options) =>
475
+ api.transcribeVoice({ dataBase64, prompt, ...options }),
476
+ composerContext: draft,
477
+ onTranscript: (text) => setDraft((prev) => (prev ? `${prev} ${text}` : text)),
478
+ onError: (msg) => setError(msg),
479
+ });
480
+ const canUseVoiceInput = Platform.OS !== 'web';
481
+
482
+ const clearPendingScrollRetries = useCallback(() => {
483
+ for (const timeoutId of scrollRetryTimeoutsRef.current) {
484
+ clearTimeout(timeoutId);
485
+ }
486
+ scrollRetryTimeoutsRef.current = [];
487
+ }, []);
488
+
489
+ const scrollToBottomReliable = useCallback(
490
+ (animated = true) => {
491
+ clearPendingScrollRetries();
492
+ const delays = [0, 70, 180, 320];
493
+ scrollRetryTimeoutsRef.current = delays.map((delay, index) =>
494
+ setTimeout(() => {
495
+ requestAnimationFrame(() => {
496
+ scrollRef.current?.scrollToEnd({
497
+ animated: index === 0 ? animated : false,
498
+ });
499
+ });
500
+ }, delay)
501
+ );
502
+ },
503
+ [clearPendingScrollRetries]
504
+ );
505
+
506
+ useEffect(() => {
507
+ return () => {
508
+ clearPendingScrollRetries();
509
+ };
510
+ }, [clearPendingScrollRetries]);
511
+
512
+ useEffect(() => {
513
+ const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
514
+ const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
515
+ const showSub = Keyboard.addListener(showEvent, () => setKeyboardVisible(true));
516
+ const hideSub = Keyboard.addListener(hideEvent, () => setKeyboardVisible(false));
517
+ return () => {
518
+ showSub.remove();
519
+ hideSub.remove();
520
+ };
521
+ }, []);
522
+
430
523
  // Ref so the WS handler always reads the latest chat ID without
431
524
  // needing to re-subscribe on every change.
432
525
  const chatIdRef = useRef<string | null>(null);
@@ -1217,6 +1310,9 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
1217
1310
  setUploadingAttachment(false);
1218
1311
  setActiveTurnId(null);
1219
1312
  setStoppingTurn(false);
1313
+ setQueuedMessages([]);
1314
+ setQueueDispatching(false);
1315
+ setQueuePaused(false);
1220
1316
  setActivity({
1221
1317
  tone: 'idle',
1222
1318
  title: 'Ready',
@@ -1846,9 +1942,9 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
1846
1942
  ],
1847
1943
  };
1848
1944
  });
1849
- setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 50);
1945
+ scrollToBottomReliable(true);
1850
1946
  },
1851
- [selectedChatId]
1947
+ [scrollToBottomReliable, selectedChatId]
1852
1948
  );
1853
1949
 
1854
1950
  const appendLocalSystemMessage = useCallback(
@@ -1879,9 +1975,9 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
1879
1975
  ],
1880
1976
  };
1881
1977
  });
1882
- setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 50);
1978
+ scrollToBottomReliable(true);
1883
1979
  },
1884
- [selectedChatId]
1980
+ [scrollToBottomReliable, selectedChatId]
1885
1981
  );
1886
1982
 
1887
1983
  const appendStopSystemMessageIfNeeded = useCallback(() => {
@@ -2238,7 +2334,7 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2238
2334
  messages: [...prev.messages, optimisticMessage],
2239
2335
  };
2240
2336
  });
2241
- setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 50);
2337
+ scrollToBottomReliable(true);
2242
2338
 
2243
2339
  try {
2244
2340
  setSending(true);
@@ -2458,6 +2554,7 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2458
2554
  selectedCollaborationMode,
2459
2555
  handleTurnFailure,
2460
2556
  rememberChatModelPreference,
2557
+ scrollToBottomReliable,
2461
2558
  startNewChat,
2462
2559
  ]
2463
2560
  );
@@ -2466,11 +2563,15 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2466
2563
  async (chatId: string) => {
2467
2564
  const requestId = loadChatRequestRef.current + 1;
2468
2565
  loadChatRequestRef.current = requestId;
2566
+ let loadedSuccessfully = false;
2567
+ let loadedMessageCount = 0;
2469
2568
  try {
2470
2569
  const chat = await api.getChat(chatId);
2471
2570
  if (requestId !== loadChatRequestRef.current) {
2472
2571
  return;
2473
2572
  }
2573
+ loadedSuccessfully = true;
2574
+ loadedMessageCount = chat.messages.length;
2474
2575
  setSelectedChatId(chatId);
2475
2576
  setSelectedChat(chat);
2476
2577
  setError(null);
@@ -2523,7 +2624,23 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2523
2624
  detail: (err as Error).message,
2524
2625
  });
2525
2626
  } finally {
2526
- if (requestId === loadChatRequestRef.current) {
2627
+ if (requestId !== loadChatRequestRef.current) {
2628
+ return;
2629
+ }
2630
+
2631
+ if (loadedSuccessfully) {
2632
+ // Keep spinner visible until initial bottom sync settles for long threads.
2633
+ scrollToBottomReliable(false);
2634
+ const revealDelayMs =
2635
+ loadedMessageCount >= LARGE_CHAT_MESSAGE_COUNT_THRESHOLD
2636
+ ? LARGE_CHAT_OPEN_REVEAL_DELAY_MS
2637
+ : CHAT_OPEN_REVEAL_DELAY_MS;
2638
+ setTimeout(() => {
2639
+ if (requestId === loadChatRequestRef.current) {
2640
+ setOpeningChatId(null);
2641
+ }
2642
+ }, revealDelayMs);
2643
+ } else {
2527
2644
  setOpeningChatId(null);
2528
2645
  }
2529
2646
  }
@@ -2534,18 +2651,20 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2534
2651
  bumpRunWatchdog,
2535
2652
  clearRunWatchdog,
2536
2653
  refreshPendingApprovalsForThread,
2654
+ scrollToBottomReliable,
2537
2655
  ]
2538
2656
  );
2539
2657
 
2540
2658
  const openChatThread = useCallback(
2541
2659
  (id: string, optimisticChat?: Chat | null) => {
2542
- const canReuseSnapshot = Boolean(
2660
+ const hasSnapshot = Boolean(
2543
2661
  optimisticChat &&
2544
2662
  optimisticChat.id === id &&
2545
2663
  optimisticChat.messages.length > 0
2546
2664
  );
2547
2665
 
2548
2666
  setSelectedChatId(id);
2667
+ setOpeningChatId(id);
2549
2668
  setSending(false);
2550
2669
  setCreating(false);
2551
2670
  setError(null);
@@ -2560,41 +2679,19 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2560
2679
  setActivePlan(null);
2561
2680
  setActiveTurnId(null);
2562
2681
  setStoppingTurn(false);
2682
+ setQueuedMessages([]);
2683
+ setQueueDispatching(false);
2684
+ setQueuePaused(false);
2563
2685
  stopRequestedRef.current = false;
2564
2686
  stopSystemMessageLoggedRef.current = false;
2565
2687
 
2566
- if (canReuseSnapshot && optimisticChat) {
2688
+ if (hasSnapshot && optimisticChat) {
2567
2689
  setSelectedChat(optimisticChat);
2568
- setOpeningChatId(null);
2569
- setActivity(
2570
- optimisticChat.status === 'running'
2571
- ? {
2572
- tone: 'running',
2573
- title: 'Working',
2574
- }
2575
- : optimisticChat.status === 'complete'
2576
- ? {
2577
- tone: 'complete',
2578
- title: 'Turn completed',
2579
- }
2580
- : optimisticChat.status === 'error'
2581
- ? {
2582
- tone: 'error',
2583
- title: 'Turn failed',
2584
- detail: optimisticChat.lastError ?? undefined,
2585
- }
2586
- : {
2587
- tone: 'idle',
2588
- title: 'Ready',
2589
- }
2590
- );
2591
- } else {
2592
- setOpeningChatId(id);
2593
- setActivity({
2594
- tone: 'running',
2595
- title: 'Opening chat',
2596
- });
2597
2690
  }
2691
+ setActivity({
2692
+ tone: 'running',
2693
+ title: 'Opening chat',
2694
+ });
2598
2695
 
2599
2696
  applyThreadRuntimeSnapshot(id);
2600
2697
  void refreshPendingApprovalsForThread(id);
@@ -2687,6 +2784,7 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2687
2784
  lastMessagePreview: content.slice(0, 50),
2688
2785
  messages: [...created.messages, optimisticMessage],
2689
2786
  });
2787
+ scrollToBottomReliable(true);
2690
2788
 
2691
2789
  setActivity({
2692
2790
  tone: 'running',
@@ -2769,6 +2867,7 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2769
2867
  bumpRunWatchdog,
2770
2868
  clearRunWatchdog,
2771
2869
  rememberChatModelPreference,
2870
+ scrollToBottomReliable,
2772
2871
  ]);
2773
2872
 
2774
2873
  const sendMessageContent = useCallback(
@@ -2777,21 +2876,29 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2777
2876
  options?: {
2778
2877
  allowSlashCommands?: boolean;
2779
2878
  collaborationMode?: CollaborationMode;
2879
+ mentions?: MentionInput[];
2880
+ localImages?: LocalImageInput[];
2881
+ clearComposer?: boolean;
2780
2882
  }
2781
2883
  ) => {
2782
2884
  const content = rawContent.trim();
2783
2885
  if (!selectedChatId || !content) {
2784
- return;
2886
+ return false;
2785
2887
  }
2786
2888
 
2889
+ const shouldClearComposer = options?.clearComposer ?? true;
2787
2890
  if (options?.allowSlashCommands && (await handleSlashCommand(content))) {
2788
- setDraft('');
2789
- return;
2891
+ if (shouldClearComposer) {
2892
+ setDraft('');
2893
+ }
2894
+ return true;
2790
2895
  }
2791
2896
  const resolvedCollaborationMode =
2792
2897
  options?.collaborationMode ?? selectedCollaborationMode;
2793
- const turnMentions = pendingMentionPaths.map((path) => toMentionInput(path));
2794
- const turnLocalImages = pendingLocalImagePaths.map((path) => ({ path }));
2898
+ const turnMentions =
2899
+ options?.mentions ?? pendingMentionPaths.map((path) => toMentionInput(path));
2900
+ const turnLocalImages =
2901
+ options?.localImages ?? pendingLocalImagePaths.map((path) => ({ path }));
2795
2902
  const optimisticContent = toOptimisticUserContent(content, turnMentions, turnLocalImages);
2796
2903
 
2797
2904
  const optimisticMessage: ChatTranscriptMessage = {
@@ -2801,7 +2908,9 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2801
2908
  createdAt: new Date().toISOString(),
2802
2909
  };
2803
2910
 
2804
- setDraft('');
2911
+ if (shouldClearComposer) {
2912
+ setDraft('');
2913
+ }
2805
2914
  setSelectedChat((prev) => {
2806
2915
  if (!prev) return prev;
2807
2916
  return {
@@ -2809,7 +2918,7 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2809
2918
  messages: [...prev.messages, optimisticMessage],
2810
2919
  };
2811
2920
  });
2812
- setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 50);
2921
+ scrollToBottomReliable(true);
2813
2922
 
2814
2923
  try {
2815
2924
  setSending(true);
@@ -2852,8 +2961,10 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2852
2961
  selectedEffort ?? activeEffort
2853
2962
  );
2854
2963
  setSelectedChat(updated);
2855
- setPendingMentionPaths([]);
2856
- setPendingLocalImagePaths([]);
2964
+ if (shouldClearComposer) {
2965
+ setPendingMentionPaths([]);
2966
+ setPendingLocalImagePaths([]);
2967
+ }
2857
2968
  setError(null);
2858
2969
  if (updated.status === 'complete') {
2859
2970
  setActivity({
@@ -2882,9 +2993,12 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2882
2993
  }
2883
2994
  } catch (err) {
2884
2995
  handleTurnFailure(err);
2996
+ return false;
2885
2997
  } finally {
2886
2998
  setSending(false);
2887
2999
  }
3000
+
3001
+ return true;
2888
3002
  },
2889
3003
  [
2890
3004
  activeEffort,
@@ -2902,12 +3016,124 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2902
3016
  bumpRunWatchdog,
2903
3017
  clearRunWatchdog,
2904
3018
  rememberChatModelPreference,
3019
+ scrollToBottomReliable,
2905
3020
  ]
2906
3021
  );
2907
3022
 
2908
3023
  const sendMessage = useCallback(async () => {
2909
- await sendMessageContent(draft, { allowSlashCommands: true });
2910
- }, [draft, sendMessageContent]);
3024
+ const content = draft.trim();
3025
+ if (!content) {
3026
+ return;
3027
+ }
3028
+
3029
+ setQueuePaused(false);
3030
+
3031
+ if (uploadingAttachment) {
3032
+ setError('Please wait for attachments to finish uploading.');
3033
+ return;
3034
+ }
3035
+
3036
+ if (await handleSlashCommand(content)) {
3037
+ setDraft('');
3038
+ return;
3039
+ }
3040
+
3041
+ const isTurnBlocked =
3042
+ sending ||
3043
+ creating ||
3044
+ stoppingTurn ||
3045
+ Boolean(activeTurnIdRef.current) ||
3046
+ Boolean(pendingApproval?.id) ||
3047
+ Boolean(pendingUserInputRequest?.id) ||
3048
+ (selectedChat ? isChatLikelyRunning(selectedChat) : false);
3049
+
3050
+ if (isTurnBlocked) {
3051
+ const queuedMentions = pendingMentionPaths.map((path) => toMentionInput(path));
3052
+ const queuedLocalImages = pendingLocalImagePaths.map((path) => ({ path }));
3053
+ setQueuedMessages((prev) => [
3054
+ ...prev,
3055
+ {
3056
+ content,
3057
+ mentions: queuedMentions,
3058
+ localImages: queuedLocalImages,
3059
+ collaborationMode: selectedCollaborationMode,
3060
+ },
3061
+ ]);
3062
+ setDraft('');
3063
+ setPendingMentionPaths([]);
3064
+ setPendingLocalImagePaths([]);
3065
+ setError(null);
3066
+ return;
3067
+ }
3068
+
3069
+ await sendMessageContent(content, { allowSlashCommands: false });
3070
+ }, [
3071
+ creating,
3072
+ draft,
3073
+ handleSlashCommand,
3074
+ pendingApproval?.id,
3075
+ pendingLocalImagePaths,
3076
+ pendingMentionPaths,
3077
+ pendingUserInputRequest?.id,
3078
+ selectedChat,
3079
+ selectedCollaborationMode,
3080
+ sendMessageContent,
3081
+ sending,
3082
+ stoppingTurn,
3083
+ setQueuePaused,
3084
+ uploadingAttachment,
3085
+ ]);
3086
+
3087
+ useEffect(() => {
3088
+ if (!selectedChatId || queuedMessages.length === 0 || queueDispatching || queuePaused) {
3089
+ return;
3090
+ }
3091
+
3092
+ const isTurnBlocked =
3093
+ sending ||
3094
+ creating ||
3095
+ stoppingTurn ||
3096
+ uploadingAttachment ||
3097
+ Boolean(activeTurnId) ||
3098
+ Boolean(pendingApproval?.id) ||
3099
+ Boolean(pendingUserInputRequest?.id) ||
3100
+ (selectedChat ? isChatLikelyRunning(selectedChat) : false);
3101
+ if (isTurnBlocked) {
3102
+ return;
3103
+ }
3104
+
3105
+ const nextMessage = queuedMessages[0];
3106
+ setQueueDispatching(true);
3107
+ void (async () => {
3108
+ const sent = await sendMessageContent(nextMessage.content, {
3109
+ allowSlashCommands: false,
3110
+ collaborationMode: nextMessage.collaborationMode,
3111
+ mentions: nextMessage.mentions,
3112
+ localImages: nextMessage.localImages,
3113
+ clearComposer: false,
3114
+ });
3115
+ if (sent) {
3116
+ setQueuedMessages((prev) => prev.slice(1));
3117
+ } else {
3118
+ setQueuePaused(true);
3119
+ }
3120
+ setQueueDispatching(false);
3121
+ })();
3122
+ }, [
3123
+ activeTurnId,
3124
+ creating,
3125
+ pendingApproval?.id,
3126
+ pendingUserInputRequest?.id,
3127
+ queueDispatching,
3128
+ queuePaused,
3129
+ queuedMessages,
3130
+ selectedChat,
3131
+ selectedChatId,
3132
+ sendMessageContent,
3133
+ sending,
3134
+ stoppingTurn,
3135
+ uploadingAttachment,
3136
+ ]);
2911
3137
 
2912
3138
  const handleInlineOptionSelect = useCallback(
2913
3139
  (value: string) => {
@@ -2920,8 +3146,11 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2920
3146
  !selectedChatId ||
2921
3147
  sending ||
2922
3148
  creating ||
3149
+ stoppingTurn ||
3150
+ Boolean(activeTurnId) ||
2923
3151
  Boolean(pendingApproval?.id) ||
2924
- Boolean(pendingUserInputRequest?.id);
3152
+ Boolean(pendingUserInputRequest?.id) ||
3153
+ (selectedChat ? isChatLikelyRunning(selectedChat) : false);
2925
3154
  if (cannotAutoSend) {
2926
3155
  setDraft(option);
2927
3156
  return;
@@ -2931,11 +3160,14 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2931
3160
  },
2932
3161
  [
2933
3162
  creating,
3163
+ activeTurnId,
2934
3164
  pendingApproval?.id,
2935
3165
  pendingUserInputRequest?.id,
3166
+ selectedChat,
2936
3167
  selectedChatId,
2937
3168
  sendMessageContent,
2938
3169
  sending,
3170
+ stoppingTurn,
2939
3171
  ]
2940
3172
  );
2941
3173
 
@@ -2948,7 +3180,7 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2948
3180
 
2949
3181
  if (event.method === 'thread/name/updated') {
2950
3182
  const params = toRecord(event.params);
2951
- const threadId = readString(params?.threadId) ?? readString(params?.thread_id);
3183
+ const threadId = extractNotificationThreadId(params);
2952
3184
  if (!threadId || threadId !== currentId) {
2953
3185
  return;
2954
3186
  }
@@ -2979,15 +3211,12 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
2979
3211
  if (!codexEventType) {
2980
3212
  return;
2981
3213
  }
2982
- const threadId =
2983
- readString(msg?.thread_id) ??
2984
- readString(msg?.threadId) ??
2985
- readString(params?.thread_id) ??
2986
- readString(params?.threadId) ??
2987
- readString(params?.conversationId) ??
2988
- readString(msg?.conversation_id);
3214
+ const threadId = extractNotificationThreadId(params, msg);
2989
3215
 
2990
3216
  if (!currentId) {
3217
+ if (threadId) {
3218
+ cacheCodexRuntimeForThread(threadId, codexEventType, msg);
3219
+ }
2991
3220
  return;
2992
3221
  }
2993
3222
 
@@ -3077,7 +3306,7 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
3077
3306
  title: 'Thinking',
3078
3307
  }
3079
3308
  );
3080
- setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 50);
3309
+ scrollToBottomReliable(true);
3081
3310
  return;
3082
3311
  }
3083
3312
 
@@ -3271,7 +3500,7 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
3271
3500
  title: 'Thinking',
3272
3501
  }
3273
3502
  );
3274
- setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 50);
3503
+ scrollToBottomReliable(true);
3275
3504
  return;
3276
3505
  }
3277
3506
 
@@ -4056,9 +4285,26 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
4056
4285
  // wipe streaming text, active commands, and the watchdog.
4057
4286
  if (event.method === 'thread/status/changed') {
4058
4287
  const params = toRecord(event.params);
4059
- const threadId =
4060
- readString(params?.threadId) ?? readString(params?.thread_id);
4288
+ const threadId = extractNotificationThreadId(params);
4289
+ const statusHint = extractExternalStatusHint(params);
4290
+ const hasExplicitRunningStatus = Boolean(
4291
+ statusHint && EXTERNAL_RUNNING_STATUS_HINTS.has(statusHint)
4292
+ );
4293
+ const hasExplicitTerminalStatus = Boolean(
4294
+ statusHint &&
4295
+ (EXTERNAL_ERROR_STATUS_HINTS.has(statusHint) ||
4296
+ EXTERNAL_COMPLETE_STATUS_HINTS.has(statusHint))
4297
+ );
4061
4298
  if (threadId && threadId === currentId) {
4299
+ if (!hasExplicitTerminalStatus) {
4300
+ bumpRunWatchdog();
4301
+ setActivity((prev) =>
4302
+ prev.tone === 'running'
4303
+ ? prev
4304
+ : { tone: 'running', title: 'Working' }
4305
+ );
4306
+ }
4307
+
4062
4308
  api
4063
4309
  .getChatSummary(threadId)
4064
4310
  .then((summary) => {
@@ -4077,26 +4323,79 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
4077
4323
  };
4078
4324
  });
4079
4325
 
4080
- if (isChatSummaryLikelyRunning(summary)) {
4326
+ const shouldPreserveRunning =
4327
+ !hasExplicitTerminalStatus &&
4328
+ runWatchdogUntilRef.current > Date.now();
4329
+ const shouldShowRunning =
4330
+ hasExplicitRunningStatus ||
4331
+ isChatSummaryLikelyRunning(summary) ||
4332
+ shouldPreserveRunning;
4333
+
4334
+ if (shouldShowRunning) {
4081
4335
  bumpRunWatchdog();
4082
4336
  setActivity((prev) =>
4083
4337
  prev.tone === 'running'
4084
4338
  ? prev
4085
4339
  : { tone: 'running', title: 'Working' }
4086
4340
  );
4341
+ } else {
4342
+ clearRunWatchdog();
4343
+ setActiveTurnId(null);
4344
+ setStoppingTurn(false);
4345
+ if (!pendingApprovalId && !pendingUserInputRequestId) {
4346
+ setActiveCommands([]);
4347
+ setStreamingText(null);
4348
+ reasoningSummaryRef.current = {};
4349
+ codexReasoningBufferRef.current = '';
4350
+ hadCommandRef.current = false;
4351
+ setActivity(() => {
4352
+ if (statusHint && EXTERNAL_ERROR_STATUS_HINTS.has(statusHint)) {
4353
+ return {
4354
+ tone: 'error',
4355
+ title: 'Turn failed',
4356
+ detail: summary.lastError ?? undefined,
4357
+ };
4358
+ }
4359
+
4360
+ if (statusHint && EXTERNAL_COMPLETE_STATUS_HINTS.has(statusHint)) {
4361
+ return {
4362
+ tone: 'complete',
4363
+ title: 'Turn completed',
4364
+ };
4365
+ }
4366
+
4367
+ return summary.status === 'error'
4368
+ ? {
4369
+ tone: 'error',
4370
+ title: 'Turn failed',
4371
+ detail: summary.lastError ?? undefined,
4372
+ }
4373
+ : summary.status === 'complete'
4374
+ ? {
4375
+ tone: 'complete',
4376
+ title: 'Turn completed',
4377
+ }
4378
+ : {
4379
+ tone: 'idle',
4380
+ title: 'Ready',
4381
+ };
4382
+ });
4383
+ }
4087
4384
  }
4088
4385
  })
4089
4386
  .catch(() => {});
4090
4387
 
4091
4388
  scheduleExternalStatusFullSync(threadId);
4092
4389
  } else if (threadId) {
4093
- cacheThreadTurnState(threadId, {
4094
- runWatchdogUntil: Date.now() + RUN_WATCHDOG_MS,
4095
- });
4096
- cacheThreadActivity(threadId, {
4097
- tone: 'running',
4098
- title: 'Working',
4099
- });
4390
+ if (!hasExplicitTerminalStatus) {
4391
+ cacheThreadTurnState(threadId, {
4392
+ runWatchdogUntil: Date.now() + RUN_WATCHDOG_MS,
4393
+ });
4394
+ cacheThreadActivity(threadId, {
4395
+ tone: 'running',
4396
+ title: 'Working',
4397
+ });
4398
+ }
4100
4399
  void refreshPendingApprovalsForThread(threadId);
4101
4400
  }
4102
4401
  return;
@@ -4148,6 +4447,7 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
4148
4447
  scheduleExternalStatusFullSync,
4149
4448
  registerTurnStarted,
4150
4449
  pushActiveCommand,
4450
+ scrollToBottomReliable,
4151
4451
  upsertThreadRuntimeSnapshot,
4152
4452
  ]);
4153
4453
 
@@ -4179,9 +4479,19 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
4179
4479
  return isUnchanged ? prev : latest;
4180
4480
  });
4181
4481
 
4182
- const shouldRunFromChat = isChatLikelyRunning(latest);
4482
+ const hasAssistantProgress = didAssistantMessageProgress(selectedChat, latest);
4483
+ const hasPendingUserMessage = hasRecentUnansweredUserTurn(latest);
4484
+ const shouldRunFromChat =
4485
+ isChatLikelyRunning(latest) ||
4486
+ hasAssistantProgress ||
4487
+ hasPendingUserMessage;
4183
4488
  const shouldRunFromWatchdog = runWatchdogUntilRef.current > Date.now();
4184
4489
  const shouldShowRunning = shouldRunFromChat || shouldRunFromWatchdog;
4490
+ const shouldRefreshWatchdog = shouldRunFromChat;
4491
+ const watchdogDurationMs =
4492
+ hasAssistantProgress && !isChatLikelyRunning(latest)
4493
+ ? Math.floor(RUN_WATCHDOG_MS / 4)
4494
+ : RUN_WATCHDOG_MS;
4185
4495
 
4186
4496
  if (shouldShowRunning && !hasPendingApproval && !hasPendingUserInput) {
4187
4497
  setActivity((prev) => {
@@ -4194,13 +4504,22 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
4194
4504
  ) {
4195
4505
  return prev;
4196
4506
  }
4197
- bumpRunWatchdog();
4507
+ if (shouldRefreshWatchdog) {
4508
+ bumpRunWatchdog(watchdogDurationMs);
4509
+ }
4198
4510
  return prev.tone === 'running'
4199
4511
  ? prev
4200
- : { tone: 'running', title: 'Working' };
4512
+ : { tone: 'running', title: hasAssistantProgress ? 'Thinking' : 'Working' };
4201
4513
  });
4202
4514
  } else if (!hasPendingApproval && !hasPendingUserInput) {
4203
4515
  clearRunWatchdog();
4516
+ setActiveCommands([]);
4517
+ setStreamingText(null);
4518
+ setActiveTurnId(null);
4519
+ setStoppingTurn(false);
4520
+ reasoningSummaryRef.current = {};
4521
+ codexReasoningBufferRef.current = '';
4522
+ hadCommandRef.current = false;
4204
4523
  setActivity((prev) => {
4205
4524
  if (latest.status === 'error') {
4206
4525
  return {
@@ -4344,32 +4663,52 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
4344
4663
 
4345
4664
  const handleComposerFocus = useCallback(() => {
4346
4665
  requestAnimationFrame(() => {
4347
- scrollRef.current?.scrollToEnd({ animated: true });
4666
+ scrollToBottomReliable(true);
4348
4667
  });
4349
- }, []);
4668
+ }, [scrollToBottomReliable]);
4350
4669
 
4351
4670
  const handleSubmit = selectedChat ? sendMessage : createChat;
4352
4671
  const isTurnLoading = sending || creating;
4353
4672
  const isLoading = isTurnLoading || uploadingAttachment;
4354
4673
  const isStreaming = sending || creating || Boolean(streamingText);
4355
4674
  const isOpeningChat = Boolean(openingChatId);
4356
- const isOpeningDifferentChat =
4357
- Boolean(openingChatId) && selectedChat?.id !== openingChatId;
4675
+ const shouldShowComposer = !isOpeningChat;
4358
4676
  const isTurnLikelyRunning =
4359
4677
  Boolean(activeTurnId) || (selectedChat ? isChatLikelyRunning(selectedChat) : false);
4678
+ const queuedMessagesDetail =
4679
+ queuedMessages.length > 0
4680
+ ? queuePaused
4681
+ ? `${String(queuedMessages.length)} queued (paused)`
4682
+ : `${String(queuedMessages.length)} queued`
4683
+ : undefined;
4684
+ const activityDetail = queuedMessagesDetail
4685
+ ? activity.detail
4686
+ ? `${activity.detail} · ${queuedMessagesDetail}`
4687
+ : queuedMessagesDetail
4688
+ : activity.detail;
4360
4689
  const showActivity =
4361
4690
  isLoading ||
4362
4691
  isOpeningChat ||
4692
+ Boolean(queuedMessagesDetail) ||
4363
4693
  activity.tone !== 'idle' ||
4364
- activity.title !== 'Ready' ||
4365
- Boolean(activity.detail);
4366
- const headerTitle = isOpeningDifferentChat
4367
- ? 'Opening chat'
4368
- : selectedChat?.title?.trim() || 'New chat';
4694
+ Boolean(activityDetail);
4695
+ const headerTitle = isOpeningChat ? 'Opening chat' : selectedChat?.title?.trim() || 'New chat';
4369
4696
  const workspaceLabel = selectedChat?.cwd?.trim() || 'Workspace not set';
4370
4697
  const defaultStartWorkspaceLabel =
4371
4698
  preferredStartCwd ?? 'Bridge default workspace';
4372
4699
  const showSlashSuggestions = slashSuggestions.length > 0 && draft.trimStart().startsWith('/');
4700
+ const showFloatingActivity =
4701
+ showActivity && shouldShowComposer && Boolean(selectedChat) && !isOpeningChat;
4702
+ const chatBottomInset = shouldShowComposer
4703
+ ? spacing.lg + (showFloatingActivity ? spacing.xxl + spacing.sm : 0)
4704
+ : Math.max(spacing.xxl, safeAreaInsets.bottom + spacing.lg);
4705
+
4706
+ useEffect(() => {
4707
+ if (!selectedChat || isOpeningChat || !showActivity) {
4708
+ return;
4709
+ }
4710
+ scrollToBottomReliable(false);
4711
+ }, [isOpeningChat, scrollToBottomReliable, selectedChat, showActivity]);
4373
4712
 
4374
4713
  return (
4375
4714
  <View style={styles.container}>
@@ -4381,7 +4720,7 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
4381
4720
  onRightActionPress={selectedChat ? handleOpenGit : undefined}
4382
4721
  />
4383
4722
 
4384
- {selectedChat ? (
4723
+ {selectedChat && !isOpeningChat ? (
4385
4724
  <View style={styles.sessionMetaRow}>
4386
4725
  <Pressable style={styles.workspaceBar} onPress={handleOpenGit}>
4387
4726
  <Ionicons name="folder-open-outline" size={14} color={colors.textMuted} />
@@ -4418,9 +4757,10 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
4418
4757
 
4419
4758
  <KeyboardAvoidingView
4420
4759
  style={styles.keyboardAvoiding}
4421
- behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
4760
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
4761
+ enabled={Platform.OS === 'ios'}
4422
4762
  >
4423
- {selectedChat && !isOpeningDifferentChat ? (
4763
+ {selectedChat && !isOpeningChat ? (
4424
4764
  <ChatView
4425
4765
  chat={selectedChat}
4426
4766
  activePlan={activePlan?.threadId === selectedChat.id ? activePlan : null}
@@ -4430,6 +4770,8 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
4430
4770
  isStreaming={isStreaming}
4431
4771
  inlineChoicesEnabled={!pendingUserInputRequest && !pendingApproval && !isLoading}
4432
4772
  onInlineOptionSelect={handleInlineOptionSelect}
4773
+ onAutoScroll={scrollToBottomReliable}
4774
+ bottomInset={chatBottomInset}
4433
4775
  />
4434
4776
  ) : isOpeningChat ? (
4435
4777
  <View style={styles.chatLoadingContainer}>
@@ -4448,70 +4790,96 @@ export const MainScreen = forwardRef<MainScreenHandle, MainScreenProps>(
4448
4790
  />
4449
4791
  )}
4450
4792
 
4451
- <View style={styles.composerContainer}>
4452
- {error ? <Text style={styles.errorText}>{error}</Text> : null}
4453
- {pendingApproval ? (
4454
- <ApprovalBanner
4455
- approval={pendingApproval}
4456
- onResolve={handleResolveApproval}
4457
- />
4458
- ) : null}
4459
- {showActivity ? (
4793
+ {showFloatingActivity ? (
4794
+ <View
4795
+ pointerEvents="none"
4796
+ style={[
4797
+ styles.activityOverlay,
4798
+ { bottom: composerHeight + spacing.sm },
4799
+ ]}
4800
+ >
4460
4801
  <ActivityBar
4461
4802
  title={activity.title}
4462
- detail={activity.detail}
4803
+ detail={activityDetail}
4463
4804
  tone={activity.tone}
4464
4805
  />
4465
- ) : null}
4466
- {showSlashSuggestions ? (
4467
- <ScrollView
4468
- style={[
4469
- styles.slashSuggestions,
4470
- { maxHeight: slashSuggestionsMaxHeight },
4471
- ]}
4472
- contentContainerStyle={styles.slashSuggestionsContent}
4473
- keyboardShouldPersistTaps="handled"
4474
- nestedScrollEnabled
4475
- >
4476
- {slashSuggestions.map((command, index) => {
4477
- const suffix = command.argsHint ? ` ${command.argsHint}` : '';
4478
- return (
4479
- <Pressable
4480
- key={`${command.name}-${String(index)}`}
4481
- onPress={() => setDraft(`/${command.name}${command.argsHint ? ' ' : ''}`)}
4482
- style={({ pressed }) => [
4483
- styles.slashSuggestionItem,
4484
- index === slashSuggestions.length - 1 &&
4485
- styles.slashSuggestionItemLast,
4486
- pressed && styles.slashSuggestionItemPressed,
4487
- ]}
4488
- >
4489
- <Text style={styles.slashSuggestionTitle}>{`/${command.name}${suffix}`}</Text>
4490
- <Text style={styles.slashSuggestionSummary} numberOfLines={1}>
4491
- {command.mobileSupported
4492
- ? command.summary
4493
- : `${command.summary} · CLI only`}
4494
- </Text>
4495
- </Pressable>
4496
- );
4497
- })}
4498
- </ScrollView>
4499
- ) : null}
4500
- <ChatInput
4501
- value={draft}
4502
- onChangeText={setDraft}
4503
- onFocus={handleComposerFocus}
4504
- onSubmit={() => void handleSubmit()}
4505
- onStop={() => handleStopTurn()}
4506
- showStopButton={isTurnLoading || isTurnLikelyRunning || stoppingTurn}
4507
- isStopping={stoppingTurn}
4508
- onAttachPress={openAttachmentMenu}
4509
- attachments={composerAttachments}
4510
- onRemoveAttachment={removeComposerAttachment}
4511
- isLoading={isLoading}
4512
- placeholder={selectedChat ? 'Reply...' : 'Message Codex...'}
4513
- />
4514
- </View>
4806
+ </View>
4807
+ ) : null}
4808
+
4809
+ {shouldShowComposer ? (
4810
+ <View
4811
+ style={[
4812
+ styles.composerContainer,
4813
+ !keyboardVisible ? styles.composerContainerResting : null,
4814
+ ]}
4815
+ onLayout={(event) => {
4816
+ const nextHeight = Math.ceil(event.nativeEvent.layout.height);
4817
+ setComposerHeight((previousHeight) =>
4818
+ previousHeight === nextHeight ? previousHeight : nextHeight
4819
+ );
4820
+ }}
4821
+ >
4822
+ {error ? <Text style={styles.errorText}>{error}</Text> : null}
4823
+ {pendingApproval ? (
4824
+ <ApprovalBanner
4825
+ approval={pendingApproval}
4826
+ onResolve={handleResolveApproval}
4827
+ />
4828
+ ) : null}
4829
+ {showSlashSuggestions ? (
4830
+ <ScrollView
4831
+ style={[
4832
+ styles.slashSuggestions,
4833
+ { maxHeight: slashSuggestionsMaxHeight },
4834
+ ]}
4835
+ contentContainerStyle={styles.slashSuggestionsContent}
4836
+ keyboardShouldPersistTaps="handled"
4837
+ nestedScrollEnabled
4838
+ >
4839
+ {slashSuggestions.map((command, index) => {
4840
+ const suffix = command.argsHint ? ` ${command.argsHint}` : '';
4841
+ return (
4842
+ <Pressable
4843
+ key={`${command.name}-${String(index)}`}
4844
+ onPress={() => setDraft(`/${command.name}${command.argsHint ? ' ' : ''}`)}
4845
+ style={({ pressed }) => [
4846
+ styles.slashSuggestionItem,
4847
+ index === slashSuggestions.length - 1 &&
4848
+ styles.slashSuggestionItemLast,
4849
+ pressed && styles.slashSuggestionItemPressed,
4850
+ ]}
4851
+ >
4852
+ <Text style={styles.slashSuggestionTitle}>{`/${command.name}${suffix}`}</Text>
4853
+ <Text style={styles.slashSuggestionSummary} numberOfLines={1}>
4854
+ {command.mobileSupported
4855
+ ? command.summary
4856
+ : `${command.summary} · CLI only`}
4857
+ </Text>
4858
+ </Pressable>
4859
+ );
4860
+ })}
4861
+ </ScrollView>
4862
+ ) : null}
4863
+ <ChatInput
4864
+ value={draft}
4865
+ onChangeText={setDraft}
4866
+ onFocus={handleComposerFocus}
4867
+ onSubmit={() => void handleSubmit()}
4868
+ onStop={() => handleStopTurn()}
4869
+ showStopButton={isTurnLoading || isTurnLikelyRunning || stoppingTurn}
4870
+ isStopping={stoppingTurn}
4871
+ onAttachPress={openAttachmentMenu}
4872
+ attachments={composerAttachments}
4873
+ onRemoveAttachment={removeComposerAttachment}
4874
+ isLoading={isLoading}
4875
+ placeholder={selectedChat ? 'Reply...' : 'Message Codex...'}
4876
+ voiceState={canUseVoiceInput ? voiceRecorder.voiceState : 'idle'}
4877
+ onVoiceToggle={canUseVoiceInput ? voiceRecorder.toggleRecording : undefined}
4878
+ safeAreaBottomInset={safeAreaInsets.bottom}
4879
+ keyboardVisible={keyboardVisible}
4880
+ />
4881
+ </View>
4882
+ ) : null}
4515
4883
  </KeyboardAvoidingView>
4516
4884
 
4517
4885
  <Modal
@@ -5055,133 +5423,221 @@ function ChatView({
5055
5423
  isStreaming,
5056
5424
  inlineChoicesEnabled,
5057
5425
  onInlineOptionSelect,
5426
+ onAutoScroll,
5427
+ bottomInset,
5058
5428
  }: {
5059
5429
  chat: Chat;
5060
5430
  activePlan: ActivePlanState | null;
5061
5431
  activeCommands: RunEvent[];
5062
5432
  streamingText: string | null;
5063
- scrollRef: React.RefObject<ScrollView | null>;
5433
+ scrollRef: React.RefObject<FlatList<ChatTranscriptMessage> | null>;
5064
5434
  isStreaming: boolean;
5065
5435
  inlineChoicesEnabled: boolean;
5066
5436
  onInlineOptionSelect: (value: string) => void;
5437
+ onAutoScroll: (animated?: boolean) => void;
5438
+ bottomInset: number;
5067
5439
  }) {
5068
5440
  const { height: windowHeight } = useWindowDimensions();
5069
- const visibleToolBlocks = activeCommands.slice(-MAX_VISIBLE_TOOL_BLOCKS);
5441
+ const shouldStickToBottomRef = useRef(true);
5442
+ const visibleToolBlocks = useMemo(
5443
+ () => activeCommands.slice(-MAX_VISIBLE_TOOL_BLOCKS),
5444
+ [activeCommands]
5445
+ );
5070
5446
  const toolPanelMaxHeight = Math.floor(windowHeight * 0.5);
5071
- const liveTimelineText = toLiveTimelineText(activeCommands);
5447
+ const liveTimelineText = useMemo(() => toLiveTimelineText(activeCommands), [activeCommands]);
5072
5448
  const shouldShowToolPanel = visibleToolBlocks.length > 0 && !liveTimelineText;
5073
5449
 
5074
- const filtered = chat.messages.filter((msg) => {
5075
- const text = msg.content || '';
5076
- if (msg.role === 'system') return false;
5077
- if (text.includes('FINAL_TASK_RESULT_JSON')) return false;
5078
- if (text.includes('Current working directory is:')) return false;
5079
- if (text.includes('You are operating in task worktree')) return false;
5080
- if (msg.role === 'assistant' && !text.trim()) return false;
5081
- return true;
5082
- });
5450
+ const visibleMessages = useMemo(() => {
5451
+ const filtered = chat.messages.filter((msg) => {
5452
+ const text = msg.content || '';
5453
+ if (msg.role === 'system') return false;
5454
+ if (text.includes('FINAL_TASK_RESULT_JSON')) return false;
5455
+ if (text.includes('Current working directory is:')) return false;
5456
+ if (text.includes('You are operating in task worktree')) return false;
5457
+ if (msg.role === 'assistant' && !text.trim()) return false;
5458
+ return true;
5459
+ });
5083
5460
 
5084
- // For each consecutive run of assistant messages, only keep the last
5085
- // one (the final answer). Earlier ones are intermediate thinking.
5086
- const visibleMessages = filtered.filter((msg, i) => {
5087
- if (msg.role !== 'assistant') return true;
5088
- const next = filtered[i + 1];
5089
- return !next || next.role !== 'assistant';
5090
- });
5091
- const inlineChoiceSet = inlineChoicesEnabled
5092
- ? findInlineChoiceSet(visibleMessages)
5093
- : null;
5094
- const streamingPreviewText = toStreamingPreviewText(streamingText, visibleMessages);
5461
+ // For each consecutive run of assistant messages, only keep the last
5462
+ // one (the final answer). Earlier ones are intermediate thinking.
5463
+ return filtered.filter((msg, i) => {
5464
+ if (msg.role !== 'assistant') return true;
5465
+ const next = filtered[i + 1];
5466
+ return !next || next.role !== 'assistant';
5467
+ });
5468
+ }, [chat.messages]);
5469
+ const inlineChoiceSet = useMemo(
5470
+ () => (inlineChoicesEnabled ? findInlineChoiceSet(visibleMessages) : null),
5471
+ [inlineChoicesEnabled, visibleMessages]
5472
+ );
5473
+ const streamingPreviewText = useMemo(
5474
+ () => toStreamingPreviewText(streamingText, visibleMessages),
5475
+ [streamingText, visibleMessages]
5476
+ );
5477
+ const initialBottomSyncChatIdRef = useRef<string | null>(null);
5478
+ const handleScroll = useCallback(
5479
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
5480
+ const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
5481
+ const distanceFromBottom =
5482
+ contentSize.height - (contentOffset.y + layoutMeasurement.height);
5483
+ shouldStickToBottomRef.current = distanceFromBottom <= spacing.xl * 2;
5484
+ },
5485
+ []
5486
+ );
5095
5487
 
5096
- return (
5097
- <ScrollView
5098
- ref={scrollRef}
5099
- style={styles.messageList}
5100
- contentContainerStyle={styles.messageListContent}
5101
- showsVerticalScrollIndicator={false}
5102
- keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'}
5103
- keyboardShouldPersistTaps="handled"
5104
- onScrollBeginDrag={Keyboard.dismiss}
5105
- onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: false })}
5106
- >
5107
- {activePlan ? <PlanCard plan={activePlan} /> : null}
5108
- {visibleMessages.map((msg, messageIndex) => {
5109
- const showInlineChoices = inlineChoiceSet?.messageId === msg.id;
5110
- return (
5111
- <View key={`${msg.id}-${String(messageIndex)}`} style={styles.chatMessageBlock}>
5112
- <ChatMessage message={msg} />
5113
- {showInlineChoices ? (
5114
- <View style={styles.inlineChoiceOptions}>
5115
- {inlineChoiceSet.options.map((option, index) => (
5116
- <Pressable
5117
- key={`${msg.id}-${index}-${option.label}`}
5118
- style={({ pressed }) => [
5119
- styles.inlineChoiceOptionButton,
5120
- pressed && styles.inlineChoiceOptionButtonPressed,
5121
- ]}
5122
- onPress={() => onInlineOptionSelect(option.label)}
5123
- >
5124
- <View style={styles.inlineChoiceOptionRow}>
5125
- <Text style={styles.inlineChoiceOptionIndex}>{`${String(index + 1)}.`}</Text>
5126
- <Text style={styles.inlineChoiceOptionLabel}>{option.label}</Text>
5127
- </View>
5128
- {option.description.trim() ? (
5129
- <Text style={styles.inlineChoiceOptionDescription}>
5130
- {option.description}
5131
- </Text>
5132
- ) : null}
5133
- </Pressable>
5134
- ))}
5135
- <Text style={styles.inlineChoiceHint}>
5136
- Tap an option to fill the reply box.
5137
- </Text>
5138
- </View>
5139
- ) : null}
5140
- </View>
5141
- );
5142
- })}
5143
- {liveTimelineText ? (
5488
+ useEffect(() => {
5489
+ if (initialBottomSyncChatIdRef.current === chat.id) {
5490
+ return;
5491
+ }
5492
+ if (!activePlan && visibleMessages.length === 0 && !liveTimelineText && !streamingPreviewText) {
5493
+ return;
5494
+ }
5495
+
5496
+ initialBottomSyncChatIdRef.current = chat.id;
5497
+ const scrollToBottom = () => onAutoScroll(false);
5498
+ const frame = requestAnimationFrame(scrollToBottom);
5499
+ const timeout = setTimeout(scrollToBottom, 120);
5500
+
5501
+ return () => {
5502
+ cancelAnimationFrame(frame);
5503
+ clearTimeout(timeout);
5504
+ };
5505
+ }, [
5506
+ activePlan,
5507
+ chat.id,
5508
+ liveTimelineText,
5509
+ onAutoScroll,
5510
+ scrollRef,
5511
+ streamingPreviewText,
5512
+ visibleMessages.length,
5513
+ ]);
5514
+
5515
+ useEffect(() => {
5516
+ shouldStickToBottomRef.current = true;
5517
+ }, [chat.id]);
5518
+
5519
+ const messageListContentStyle = useMemo(
5520
+ () => [styles.messageListContent, { paddingBottom: bottomInset }],
5521
+ [bottomInset]
5522
+ );
5523
+ const isLargeChat = visibleMessages.length >= LARGE_CHAT_MESSAGE_COUNT_THRESHOLD;
5524
+ const aggressiveRenderBatchSize = Math.max(visibleMessages.length, 1);
5525
+ const keyExtractor = useCallback((msg: ChatTranscriptMessage) => msg.id, []);
5526
+ const renderMessageItem = useCallback<ListRenderItem<ChatTranscriptMessage>>(
5527
+ ({ item: msg }) => {
5528
+ const showInlineChoices = inlineChoiceSet?.messageId === msg.id;
5529
+ return (
5144
5530
  <View style={styles.chatMessageBlock}>
5145
- <ChatMessage
5146
- message={{
5147
- id: `live-timeline-${chat.id}`,
5148
- role: 'system',
5149
- content: liveTimelineText,
5150
- createdAt: new Date().toISOString(),
5151
- }}
5152
- />
5531
+ <ChatMessage message={msg} />
5532
+ {showInlineChoices ? (
5533
+ <View style={styles.inlineChoiceOptions}>
5534
+ {inlineChoiceSet.options.map((option, index) => (
5535
+ <Pressable
5536
+ key={`${msg.id}-${index}-${option.label}`}
5537
+ style={({ pressed }) => [
5538
+ styles.inlineChoiceOptionButton,
5539
+ pressed && styles.inlineChoiceOptionButtonPressed,
5540
+ ]}
5541
+ onPress={() => onInlineOptionSelect(option.label)}
5542
+ >
5543
+ <View style={styles.inlineChoiceOptionRow}>
5544
+ <Text style={styles.inlineChoiceOptionIndex}>{`${String(index + 1)}.`}</Text>
5545
+ <Text style={styles.inlineChoiceOptionLabel}>{option.label}</Text>
5546
+ </View>
5547
+ {option.description.trim() ? (
5548
+ <Text style={styles.inlineChoiceOptionDescription}>
5549
+ {option.description}
5550
+ </Text>
5551
+ ) : null}
5552
+ </Pressable>
5553
+ ))}
5554
+ <Text style={styles.inlineChoiceHint}>
5555
+ Tap an option to fill the reply box.
5556
+ </Text>
5557
+ </View>
5558
+ ) : null}
5153
5559
  </View>
5154
- ) : null}
5155
- {streamingPreviewText ? (
5156
- <Text style={styles.streamingText} numberOfLines={4}>
5157
- {streamingPreviewText}
5158
- </Text>
5159
- ) : null}
5160
- {shouldShowToolPanel ? (
5161
- <View style={[styles.toolPanel, { maxHeight: toolPanelMaxHeight }]}>
5162
- <ScrollView
5163
- nestedScrollEnabled
5164
- showsVerticalScrollIndicator={false}
5165
- contentContainerStyle={styles.toolPanelContent}
5166
- >
5167
- {visibleToolBlocks.map((cmd) => {
5168
- const tool = toToolBlockState(cmd);
5169
- if (!tool) {
5170
- return null;
5171
- }
5172
- return (
5173
- <ToolBlock
5174
- key={cmd.id}
5175
- command={tool.command}
5176
- status={tool.status}
5560
+ );
5561
+ },
5562
+ [inlineChoiceSet, onInlineOptionSelect]
5563
+ );
5564
+
5565
+ return (
5566
+ <View style={styles.messageListShell}>
5567
+ <FlatList
5568
+ key={chat.id}
5569
+ ref={scrollRef}
5570
+ data={visibleMessages}
5571
+ keyExtractor={keyExtractor}
5572
+ renderItem={renderMessageItem}
5573
+ style={styles.messageList}
5574
+ contentContainerStyle={messageListContentStyle}
5575
+ showsVerticalScrollIndicator={false}
5576
+ keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'}
5577
+ keyboardShouldPersistTaps="handled"
5578
+ onScrollBeginDrag={Keyboard.dismiss}
5579
+ onScroll={handleScroll}
5580
+ scrollEventThrottle={16}
5581
+ onContentSizeChange={() => {
5582
+ if (shouldStickToBottomRef.current) {
5583
+ onAutoScroll(false);
5584
+ }
5585
+ }}
5586
+ initialNumToRender={isLargeChat ? aggressiveRenderBatchSize : 16}
5587
+ maxToRenderPerBatch={isLargeChat ? aggressiveRenderBatchSize : 12}
5588
+ updateCellsBatchingPeriod={isLargeChat ? 0 : undefined}
5589
+ windowSize={isLargeChat ? 21 : 11}
5590
+ removeClippedSubviews={isLargeChat ? false : Platform.OS === 'android'}
5591
+ ListHeaderComponent={activePlan ? <PlanCard plan={activePlan} /> : null}
5592
+ ListFooterComponent={
5593
+ <>
5594
+ {liveTimelineText ? (
5595
+ <View style={styles.chatMessageBlock}>
5596
+ <ChatMessage
5597
+ message={{
5598
+ id: `live-timeline-${chat.id}`,
5599
+ role: 'system',
5600
+ content: liveTimelineText,
5601
+ createdAt: new Date().toISOString(),
5602
+ }}
5177
5603
  />
5178
- );
5179
- })}
5180
- </ScrollView>
5181
- </View>
5182
- ) : null}
5183
- {isStreaming && !streamingPreviewText && activeCommands.length === 0 ? <TypingIndicator /> : null}
5184
- </ScrollView>
5604
+ </View>
5605
+ ) : null}
5606
+ {streamingPreviewText ? (
5607
+ <Text style={styles.streamingText} numberOfLines={4}>
5608
+ {streamingPreviewText}
5609
+ </Text>
5610
+ ) : null}
5611
+ {shouldShowToolPanel ? (
5612
+ <View style={[styles.toolPanel, { maxHeight: toolPanelMaxHeight }]}>
5613
+ <ScrollView
5614
+ nestedScrollEnabled
5615
+ showsVerticalScrollIndicator={false}
5616
+ contentContainerStyle={styles.toolPanelContent}
5617
+ >
5618
+ {visibleToolBlocks.map((cmd) => {
5619
+ const tool = toToolBlockState(cmd);
5620
+ if (!tool) {
5621
+ return null;
5622
+ }
5623
+ return (
5624
+ <ToolBlock
5625
+ key={cmd.id}
5626
+ command={tool.command}
5627
+ status={tool.status}
5628
+ />
5629
+ );
5630
+ })}
5631
+ </ScrollView>
5632
+ </View>
5633
+ ) : null}
5634
+ {isStreaming && !streamingPreviewText && activeCommands.length === 0 ? (
5635
+ <TypingIndicator />
5636
+ ) : null}
5637
+ </>
5638
+ }
5639
+ />
5640
+ </View>
5185
5641
  );
5186
5642
  }
5187
5643
 
@@ -5450,10 +5906,10 @@ function findInlineChoiceSet(messages: ChatTranscriptMessage[]): {
5450
5906
  continue;
5451
5907
  }
5452
5908
 
5453
- const cueSource = `${parsed.question}\n${message.content}`.toLowerCase();
5909
+ const cueSource = parsed.question.trim();
5454
5910
  const hasCue =
5455
5911
  cueSource.includes('?') ||
5456
- INLINE_CHOICE_CUE_PHRASES.some((phrase) => cueSource.includes(phrase));
5912
+ INLINE_CHOICE_CUE_PATTERNS.some((pattern) => pattern.test(cueSource));
5457
5913
  if (!hasCue) {
5458
5914
  continue;
5459
5915
  }
@@ -6135,6 +6591,110 @@ function isCodexRunHeartbeatEvent(codexEventType: string): boolean {
6135
6591
  return CODEX_RUN_HEARTBEAT_EVENT_TYPES.has(codexEventType);
6136
6592
  }
6137
6593
 
6594
+ function normalizeExternalStatusHint(value: string | null | undefined): string | null {
6595
+ if (!value) {
6596
+ return null;
6597
+ }
6598
+
6599
+ const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]/g, '');
6600
+ return normalized.length > 0 ? normalized : null;
6601
+ }
6602
+
6603
+ function extractNotificationThreadId(
6604
+ params: Record<string, unknown> | null,
6605
+ msgArg?: Record<string, unknown> | null
6606
+ ): string | null {
6607
+ if (!params && !msgArg) {
6608
+ return null;
6609
+ }
6610
+
6611
+ const msg = msgArg ?? toRecord(params?.msg);
6612
+ const threadRecord =
6613
+ toRecord(params?.thread) ??
6614
+ toRecord(params?.threadState) ??
6615
+ toRecord(params?.thread_state) ??
6616
+ toRecord(msg?.thread);
6617
+ const turnRecord = toRecord(params?.turn) ?? toRecord(msg?.turn);
6618
+ const sourceRecord = toRecord(params?.source) ?? toRecord(msg?.source);
6619
+ const subagentThreadSpawnRecord = toRecord(
6620
+ toRecord(sourceRecord?.subagent)?.thread_spawn
6621
+ );
6622
+
6623
+ return (
6624
+ readString(msg?.thread_id) ??
6625
+ readString(msg?.threadId) ??
6626
+ readString(msg?.conversation_id) ??
6627
+ readString(msg?.conversationId) ??
6628
+ readString(params?.thread_id) ??
6629
+ readString(params?.threadId) ??
6630
+ readString(params?.conversation_id) ??
6631
+ readString(params?.conversationId) ??
6632
+ readString(threadRecord?.id) ??
6633
+ readString(threadRecord?.thread_id) ??
6634
+ readString(threadRecord?.threadId) ??
6635
+ readString(threadRecord?.conversation_id) ??
6636
+ readString(threadRecord?.conversationId) ??
6637
+ readString(turnRecord?.thread_id) ??
6638
+ readString(turnRecord?.threadId) ??
6639
+ readString(sourceRecord?.thread_id) ??
6640
+ readString(sourceRecord?.threadId) ??
6641
+ readString(sourceRecord?.conversation_id) ??
6642
+ readString(sourceRecord?.conversationId) ??
6643
+ readString(sourceRecord?.parent_thread_id) ??
6644
+ readString(sourceRecord?.parentThreadId) ??
6645
+ readString(subagentThreadSpawnRecord?.parent_thread_id) ??
6646
+ null
6647
+ );
6648
+ }
6649
+
6650
+ function extractExternalStatusHint(
6651
+ params: Record<string, unknown> | null
6652
+ ): string | null {
6653
+ if (!params) {
6654
+ return null;
6655
+ }
6656
+
6657
+ const directCandidates: unknown[] = [
6658
+ params.status,
6659
+ params.threadStatus,
6660
+ params.thread_status,
6661
+ params.state,
6662
+ params.phase,
6663
+ ];
6664
+ for (const candidate of directCandidates) {
6665
+ const direct = normalizeExternalStatusHint(readString(candidate));
6666
+ if (direct) {
6667
+ return direct;
6668
+ }
6669
+
6670
+ const candidateRecord = toRecord(candidate);
6671
+ const typed = normalizeExternalStatusHint(
6672
+ readString(candidateRecord?.type) ??
6673
+ readString(candidateRecord?.status) ??
6674
+ readString(candidateRecord?.state) ??
6675
+ readString(candidateRecord?.phase)
6676
+ );
6677
+ if (typed) {
6678
+ return typed;
6679
+ }
6680
+ }
6681
+
6682
+ const threadRecord =
6683
+ toRecord(params.thread) ?? toRecord(params.threadState) ?? toRecord(params.thread_state);
6684
+ if (!threadRecord) {
6685
+ return null;
6686
+ }
6687
+
6688
+ const nestedThreadStatus = normalizeExternalStatusHint(
6689
+ readString(threadRecord.status) ??
6690
+ readString(toRecord(threadRecord.status)?.type) ??
6691
+ readString(threadRecord.state) ??
6692
+ readString(threadRecord.phase) ??
6693
+ readString(toRecord(threadRecord.lifecycle)?.status)
6694
+ );
6695
+ return nestedThreadStatus;
6696
+ }
6697
+
6138
6698
  function isChatSummaryLikelyRunning(chat: ChatSummary): boolean {
6139
6699
  return chat.status === 'running';
6140
6700
  }
@@ -6162,6 +6722,70 @@ function isChatLikelyRunning(chat: Chat): boolean {
6162
6722
  return Date.now() - updatedAtMs < LIKELY_RUNNING_RECENT_UPDATE_MS;
6163
6723
  }
6164
6724
 
6725
+ function hasRecentUnansweredUserTurn(chat: Chat): boolean {
6726
+ let lastUserIndex = -1;
6727
+ for (let index = chat.messages.length - 1; index >= 0; index -= 1) {
6728
+ if (chat.messages[index].role === 'user') {
6729
+ lastUserIndex = index;
6730
+ break;
6731
+ }
6732
+ }
6733
+
6734
+ if (lastUserIndex < 0) {
6735
+ return false;
6736
+ }
6737
+
6738
+ for (let index = lastUserIndex + 1; index < chat.messages.length; index += 1) {
6739
+ if (chat.messages[index].role === 'assistant') {
6740
+ return false;
6741
+ }
6742
+ }
6743
+
6744
+ const lastUser = chat.messages[lastUserIndex];
6745
+ const userCreatedAtMs = Date.parse(lastUser.createdAt);
6746
+ if (!Number.isFinite(userCreatedAtMs)) {
6747
+ return false;
6748
+ }
6749
+
6750
+ return Date.now() - userCreatedAtMs < UNANSWERED_USER_RUNNING_TTL_MS;
6751
+ }
6752
+
6753
+ function didAssistantMessageProgress(previous: Chat | null, next: Chat): boolean {
6754
+ if (!previous || previous.id !== next.id) {
6755
+ return false;
6756
+ }
6757
+
6758
+ const previousLatestAssistant = latestAssistantMessage(previous.messages);
6759
+ const nextLatestAssistant = latestAssistantMessage(next.messages);
6760
+
6761
+ if (!nextLatestAssistant) {
6762
+ return false;
6763
+ }
6764
+
6765
+ if (!previousLatestAssistant) {
6766
+ return nextLatestAssistant.content.trim().length > 0;
6767
+ }
6768
+
6769
+ if (nextLatestAssistant.id === previousLatestAssistant.id) {
6770
+ return nextLatestAssistant.content.length > previousLatestAssistant.content.length;
6771
+ }
6772
+
6773
+ return (
6774
+ next.messages.length > previous.messages.length &&
6775
+ nextLatestAssistant.content.trim().length > 0
6776
+ );
6777
+ }
6778
+
6779
+ function latestAssistantMessage(messages: ChatTranscriptMessage[]): ChatTranscriptMessage | null {
6780
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
6781
+ const message = messages[index];
6782
+ if (message.role === 'assistant') {
6783
+ return message;
6784
+ }
6785
+ }
6786
+ return null;
6787
+ }
6788
+
6165
6789
  function extractFirstBoldSnippet(
6166
6790
  value: string | null | undefined,
6167
6791
  maxLength = 56
@@ -6248,6 +6872,15 @@ const styles = StyleSheet.create({
6248
6872
  composerContainer: {
6249
6873
  backgroundColor: colors.bgMain,
6250
6874
  },
6875
+ composerContainerResting: {
6876
+ marginBottom: spacing.xs,
6877
+ },
6878
+ activityOverlay: {
6879
+ position: 'absolute',
6880
+ left: 0,
6881
+ right: 0,
6882
+ zIndex: 3,
6883
+ },
6251
6884
  sessionMetaRow: {
6252
6885
  flexDirection: 'row',
6253
6886
  alignItems: 'center',
@@ -6778,10 +7411,15 @@ const styles = StyleSheet.create({
6778
7411
  },
6779
7412
 
6780
7413
  // Chat
7414
+ messageListShell: {
7415
+ flex: 1,
7416
+ },
6781
7417
  messageList: {
6782
7418
  flex: 1,
6783
7419
  },
6784
7420
  messageListContent: {
7421
+ flexGrow: 1,
7422
+ justifyContent: 'flex-end',
6785
7423
  padding: spacing.lg,
6786
7424
  paddingTop: spacing.lg,
6787
7425
  paddingBottom: spacing.xl,