clawdex-mobile 2.0.0 → 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 +11 -0
  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 +58 -363
  64. package/services/rust-bridge/Cargo.toml +2 -2
  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
@@ -2,11 +2,21 @@ export type ChatStatus = 'idle' | 'running' | 'error' | 'complete';
2
2
 
3
3
  export type ChatMessageRole = 'user' | 'assistant' | 'system';
4
4
 
5
+ export interface ChatMessageSubAgentMeta {
6
+ tool?: string;
7
+ prompt?: string;
8
+ senderThreadId?: string;
9
+ receiverThreadIds?: string[];
10
+ agentStatus?: string;
11
+ }
12
+
5
13
  export interface ChatMessage {
6
14
  id: string;
7
15
  role: ChatMessageRole;
8
16
  content: string;
9
17
  createdAt: string;
18
+ systemKind?: 'tool' | 'subAgent';
19
+ subAgentMeta?: ChatMessageSubAgentMeta;
10
20
  }
11
21
 
12
22
  export interface ChatSummary {
@@ -19,7 +29,11 @@ export interface ChatSummary {
19
29
  lastMessagePreview: string;
20
30
  cwd?: string;
21
31
  modelProvider?: string;
32
+ agentNickname?: string;
33
+ agentRole?: string;
22
34
  sourceKind?: string;
35
+ parentThreadId?: string;
36
+ subAgentDepth?: number;
23
37
  lastRunStartedAt?: string;
24
38
  lastRunFinishedAt?: string;
25
39
  lastRunDurationMs?: number;
@@ -28,8 +42,18 @@ export interface ChatSummary {
28
42
  lastError?: string;
29
43
  }
30
44
 
45
+ export interface ChatPlanSnapshot {
46
+ threadId: string;
47
+ turnId: string;
48
+ explanation: string | null;
49
+ steps: TurnPlanStep[];
50
+ }
51
+
31
52
  export interface Chat extends ChatSummary {
32
53
  messages: ChatMessage[];
54
+ latestPlan?: ChatPlanSnapshot | null;
55
+ latestTurnPlan?: ChatPlanSnapshot | null;
56
+ latestTurnStatus?: string | null;
33
57
  }
34
58
 
35
59
  export interface CreateChatRequest {
@@ -38,6 +62,7 @@ export interface CreateChatRequest {
38
62
  cwd?: string;
39
63
  model?: string;
40
64
  effort?: ReasoningEffort;
65
+ serviceTier?: ServiceTier;
41
66
  approvalPolicy?: ApprovalPolicy;
42
67
  }
43
68
 
@@ -49,12 +74,19 @@ export interface SendChatMessageRequest {
49
74
  cwd?: string;
50
75
  model?: string;
51
76
  effort?: ReasoningEffort;
77
+ serviceTier?: ServiceTier;
52
78
  approvalPolicy?: ApprovalPolicy;
53
79
  collaborationMode?: CollaborationMode;
54
80
  mentions?: MentionInput[];
55
81
  localImages?: LocalImageInput[];
56
82
  }
57
83
 
84
+ export interface SteerChatTurnRequest {
85
+ content: string;
86
+ mentions?: MentionInput[];
87
+ localImages?: LocalImageInput[];
88
+ }
89
+
58
90
  export interface MentionInput {
59
91
  path: string;
60
92
  name?: string;
@@ -82,6 +114,39 @@ export interface UploadAttachmentResponse {
82
114
  kind: AttachmentUploadKind;
83
115
  }
84
116
 
117
+ export interface WorkspaceSummary {
118
+ path: string;
119
+ chatCount: number;
120
+ }
121
+
122
+ export interface WorkspaceListResponse {
123
+ bridgeRoot: string;
124
+ allowOutsideRootCwd: boolean;
125
+ workspaces: WorkspaceSummary[];
126
+ }
127
+
128
+ export interface FileSystemListRequest {
129
+ path?: string | null;
130
+ includeHidden?: boolean;
131
+ directoriesOnly?: boolean;
132
+ }
133
+
134
+ export interface FileSystemEntry {
135
+ name: string;
136
+ path: string;
137
+ kind: string;
138
+ hidden: boolean;
139
+ selectable: boolean;
140
+ isGitRepo: boolean;
141
+ }
142
+
143
+ export interface FileSystemListResponse {
144
+ bridgeRoot: string;
145
+ path: string;
146
+ parentPath: string | null;
147
+ entries: FileSystemEntry[];
148
+ }
149
+
85
150
  export type ReasoningEffort =
86
151
  | 'none'
87
152
  | 'minimal'
@@ -90,6 +155,47 @@ export type ReasoningEffort =
90
155
  | 'high'
91
156
  | 'xhigh';
92
157
 
158
+ export type ServiceTier = 'flex' | 'fast';
159
+
160
+ export type PlanType =
161
+ | 'free'
162
+ | 'go'
163
+ | 'plus'
164
+ | 'pro'
165
+ | 'team'
166
+ | 'business'
167
+ | 'enterprise'
168
+ | 'edu'
169
+ | 'unknown';
170
+
171
+ export interface AccountCreditsSnapshot {
172
+ hasCredits: boolean;
173
+ unlimited: boolean;
174
+ balance: string | null;
175
+ }
176
+
177
+ export interface AccountRateLimitWindow {
178
+ usedPercent: number;
179
+ windowDurationMins: number | null;
180
+ resetsAt: number | null;
181
+ }
182
+
183
+ export interface AccountRateLimitSnapshot {
184
+ limitId: string | null;
185
+ limitName: string | null;
186
+ primary: AccountRateLimitWindow | null;
187
+ secondary: AccountRateLimitWindow | null;
188
+ credits: AccountCreditsSnapshot | null;
189
+ planType: PlanType | null;
190
+ }
191
+
192
+ export interface AccountSnapshot {
193
+ type: 'apiKey' | 'chatgpt' | null;
194
+ email: string | null;
195
+ planType: PlanType | null;
196
+ requiresOpenaiAuth: boolean;
197
+ }
198
+
93
199
  export type ApprovalPolicy =
94
200
  | 'untrusted'
95
201
  | 'on-request'
@@ -778,9 +778,13 @@ function extractNotificationThreadId(
778
778
  toRecord(params?.threadState) ??
779
779
  toRecord(params?.thread_state) ??
780
780
  toRecord(msg?.thread);
781
+ const threadSourceRecord = toRecord(threadRecord?.source);
781
782
  const sourceRecord = toRecord(params?.source) ?? toRecord(msg?.source);
782
783
  const subagentThreadSpawnRecord = toRecord(
783
- toRecord(sourceRecord?.subagent)?.thread_spawn
784
+ toRecord(sourceRecord?.subagent ?? sourceRecord?.subAgent)?.thread_spawn
785
+ );
786
+ const threadSubagentThreadSpawnRecord = toRecord(
787
+ toRecord(threadSourceRecord?.subagent ?? threadSourceRecord?.subAgent)?.thread_spawn
784
788
  );
785
789
 
786
790
  return (
@@ -804,6 +808,11 @@ function extractNotificationThreadId(
804
808
  readString(sourceRecord?.parent_thread_id) ??
805
809
  readString(sourceRecord?.parentThreadId) ??
806
810
  readString(subagentThreadSpawnRecord?.parent_thread_id) ??
811
+ readString(subagentThreadSpawnRecord?.parentThreadId) ??
812
+ readString(threadSourceRecord?.parent_thread_id) ??
813
+ readString(threadSourceRecord?.parentThreadId) ??
814
+ readString(threadSubagentThreadSpawnRecord?.parent_thread_id) ??
815
+ readString(threadSubagentThreadSpawnRecord?.parentThreadId) ??
807
816
  null
808
817
  );
809
818
  }
@@ -26,9 +26,9 @@ export function ChatHeader({
26
26
  <SafeAreaView edges={['top', 'left', 'right']}>
27
27
  <View style={styles.header}>
28
28
  <Pressable onPress={onOpenDrawer} hitSlop={8} style={styles.menuBtn}>
29
- <Ionicons name="menu" size={22} color={colors.textPrimary} />
29
+ <Ionicons name="menu" size={20} color={colors.textPrimary} />
30
30
  </Pressable>
31
- <BrandMark size={22} />
31
+ <BrandMark size={18} />
32
32
  {onOpenTitleMenu ? (
33
33
  <Pressable
34
34
  onPress={onOpenTitleMenu}
@@ -38,7 +38,7 @@ export function ChatHeader({
38
38
  <Text numberOfLines={1} style={styles.modelName}>
39
39
  {titleDisplay}
40
40
  </Text>
41
- <Ionicons name="chevron-down" size={14} color={colors.textMuted} />
41
+ <Ionicons name="chevron-down" size={12} color={colors.textMuted} />
42
42
  </Pressable>
43
43
  ) : (
44
44
  <View style={styles.modelNameRow}>
@@ -50,10 +50,10 @@ export function ChatHeader({
50
50
  <View style={{ flex: 1 }} />
51
51
  {onRightActionPress ? (
52
52
  <Pressable onPress={onRightActionPress} hitSlop={8} style={styles.rightBtn}>
53
- <Ionicons name={rightIconName} size={20} color={colors.textMuted} />
53
+ <Ionicons name={rightIconName} size={18} color={colors.textMuted} />
54
54
  </Pressable>
55
55
  ) : (
56
- <Ionicons name={rightIconName} size={20} color={colors.textMuted} />
56
+ <Ionicons name={rightIconName} size={18} color={colors.textMuted} />
57
57
  )}
58
58
  </View>
59
59
  </SafeAreaView>
@@ -72,13 +72,13 @@ const styles = StyleSheet.create({
72
72
  alignItems: 'center',
73
73
  gap: spacing.sm,
74
74
  paddingHorizontal: spacing.lg,
75
- paddingVertical: spacing.md,
75
+ paddingVertical: spacing.sm,
76
76
  },
77
77
  menuBtn: {
78
- padding: spacing.xs,
78
+ padding: 2,
79
79
  },
80
80
  rightBtn: {
81
- padding: spacing.xs,
81
+ padding: 2,
82
82
  },
83
83
  modelNameRow: {
84
84
  flexDirection: 'row',
@@ -91,16 +91,16 @@ const styles = StyleSheet.create({
91
91
  alignItems: 'center',
92
92
  gap: spacing.xs,
93
93
  borderRadius: 8,
94
- paddingHorizontal: spacing.xs,
95
- paddingVertical: 2,
94
+ paddingHorizontal: 2,
95
+ paddingVertical: 1,
96
96
  flexShrink: 1,
97
97
  },
98
98
  titleButtonPressed: {
99
99
  backgroundColor: colors.bgItem,
100
100
  },
101
101
  modelName: {
102
- ...typography.largeTitle,
103
- fontSize: 20,
102
+ ...typography.headline,
103
+ fontSize: 17,
104
104
  color: colors.textPrimary,
105
105
  flexShrink: 1,
106
106
  },
@@ -1,6 +1,5 @@
1
1
  import { Ionicons } from '@expo/vector-icons';
2
- import { BlurView } from 'expo-blur';
3
- import { useEffect, useState } from 'react';
2
+ import { useEffect, useState, type ReactNode } from 'react';
4
3
  import {
5
4
  ActivityIndicator,
6
5
  type NativeSyntheticEvent,
@@ -16,6 +15,8 @@ import {
16
15
  } from 'react-native';
17
16
 
18
17
  import type { VoiceState } from '../hooks/useVoiceRecorder';
18
+ import { resolveComposerBottomSpacing } from './chat-input-layout';
19
+ import { VoiceRecordingWaveform } from './VoiceRecordingWaveform';
19
20
  import { colors, radius, spacing } from '../theme';
20
21
 
21
22
  interface ChatInputProps {
@@ -25,6 +26,7 @@ interface ChatInputProps {
25
26
  onSubmit: () => void;
26
27
  onStop?: () => void;
27
28
  onAttachPress: () => void;
29
+ attachDisabled?: boolean;
28
30
  attachments?: Array<{ id: string; label: string }>;
29
31
  onRemoveAttachment?: (id: string) => void;
30
32
  isLoading: boolean;
@@ -33,8 +35,11 @@ interface ChatInputProps {
33
35
  placeholder?: string;
34
36
  onVoiceToggle?: () => void;
35
37
  voiceState?: VoiceState;
38
+ voiceRecordingDurationMillis?: number;
39
+ voiceMetering?: number | null;
36
40
  safeAreaBottomInset?: number;
37
41
  keyboardVisible?: boolean;
42
+ footer?: ReactNode;
38
43
  }
39
44
 
40
45
  export function ChatInput({
@@ -44,6 +49,7 @@ export function ChatInput({
44
49
  onSubmit,
45
50
  onStop,
46
51
  onAttachPress,
52
+ attachDisabled = false,
47
53
  attachments = [],
48
54
  onRemoveAttachment,
49
55
  isLoading,
@@ -52,8 +58,11 @@ export function ChatInput({
52
58
  placeholder = 'Message Codex...',
53
59
  onVoiceToggle,
54
60
  voiceState = 'idle',
61
+ voiceRecordingDurationMillis = 0,
62
+ voiceMetering = null,
55
63
  safeAreaBottomInset = 0,
56
64
  keyboardVisible = false,
65
+ footer = null,
57
66
  }: ChatInputProps) {
58
67
  const INPUT_TEXT_LINE_HEIGHT = 20;
59
68
  const INPUT_TEXT_VERTICAL_PADDING = Platform.OS === 'ios' ? 2 : 0;
@@ -82,30 +91,24 @@ export function ChatInput({
82
91
  const showVoiceButton = Boolean(onVoiceToggle);
83
92
  const showSendButton = canSend || isLoading;
84
93
  const inputScrollEnabled = inputHeight >= INPUT_TEXT_MAX_HEIGHT;
94
+ const showVoiceRecordingUi = voiceState === 'recording';
95
+ const showVoiceTranscribingUi = voiceState === 'transcribing';
96
+ const showVoiceStatusUi = showVoiceRecordingUi || showVoiceTranscribingUi;
85
97
  const shouldShowActionButton =
86
98
  canStop || showSendButton || showVoiceButton || voiceState !== 'idle';
87
- const baseBottomPadding =
88
- Platform.OS === 'ios'
89
- ? keyboardVisible
90
- ? spacing.sm
91
- : spacing.lg
92
- : spacing.md;
93
- const extraBottomInset = keyboardVisible ? 0 : safeAreaBottomInset;
99
+ const composerBottomSpacing = resolveComposerBottomSpacing(
100
+ Platform.OS,
101
+ safeAreaBottomInset,
102
+ keyboardVisible
103
+ );
94
104
 
95
105
  return (
96
106
  <View style={styles.shell}>
97
- <BlurView
98
- intensity={26}
99
- tint={Platform.OS === 'ios' ? 'systemUltraThinMaterialDark' : 'dark'}
100
- blurMethod="dimezisBlurViewSdk31Plus"
101
- style={StyleSheet.absoluteFill}
102
- />
103
107
  <View
104
108
  style={[
105
109
  styles.container,
106
110
  {
107
- paddingBottom:
108
- baseBottomPadding + extraBottomInset,
111
+ paddingBottom: composerBottomSpacing.totalBottomPadding,
109
112
  },
110
113
  ]}
111
114
  >
@@ -143,78 +146,102 @@ export function ChatInput({
143
146
 
144
147
  <View style={styles.row}>
145
148
  <Pressable
149
+ disabled={attachDisabled}
146
150
  onPress={onAttachPress}
147
- style={({ pressed }) => [styles.plusBtn, pressed && styles.plusBtnPressed]}
151
+ style={({ pressed }) => [
152
+ styles.plusBtn,
153
+ attachDisabled && styles.plusBtnDisabled,
154
+ pressed && !attachDisabled && styles.plusBtnPressed,
155
+ ]}
148
156
  >
149
157
  <Ionicons name="add" size={20} color={colors.textMuted} />
150
158
  </Pressable>
151
159
 
152
- <View style={styles.inputWrapper}>
153
- <Text
154
- pointerEvents="none"
155
- accessibilityElementsHidden
156
- importantForAccessibility="no-hide-descendants"
157
- style={[
158
- styles.inputMeasure,
159
- {
160
- width: inputWidth,
161
- lineHeight: INPUT_TEXT_LINE_HEIGHT,
162
- paddingVertical: INPUT_TEXT_VERTICAL_PADDING,
163
- },
164
- ]}
165
- onTextLayout={(event: NativeSyntheticEvent<TextLayoutEventData>) => {
166
- if (inputWidth <= 0) {
167
- return;
168
- }
169
- const lineCount = Math.max(1, event.nativeEvent.lines.length);
170
- const measuredHeight =
171
- lineCount * INPUT_TEXT_LINE_HEIGHT + INPUT_TEXT_VERTICAL_PADDING * 2;
172
- updateInputHeight(measuredHeight);
173
- }}
174
- >
175
- {value.length > 0 ? `${value}\u200b` : ' '}
176
- </Text>
177
- <TextInput
178
- style={[styles.input, { height: inputHeight }]}
179
- value={value}
180
- onChangeText={onChangeText}
181
- onLayout={(event) => {
182
- const nextWidth = Math.floor(event.nativeEvent.layout.width);
183
- setInputWidth((previousWidth) =>
184
- previousWidth === nextWidth ? previousWidth : nextWidth
185
- );
186
- }}
187
- onChange={(event: NativeSyntheticEvent<unknown>) => {
188
- const nativeEvent = event.nativeEvent as {
189
- contentSize?: { height?: number };
190
- };
191
- const contentHeight = nativeEvent.contentSize?.height;
192
- if (typeof contentHeight === 'number' && Number.isFinite(contentHeight)) {
193
- updateInputHeight(contentHeight);
194
- }
195
- }}
196
- onFocus={onFocus}
197
- placeholder={placeholder}
198
- placeholderTextColor={colors.textMuted}
199
- multiline
200
- scrollEnabled={inputScrollEnabled}
201
- onContentSizeChange={(event) => {
202
- updateInputHeight(event.nativeEvent.contentSize.height);
203
- }}
204
- onKeyPress={(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
205
- const keyEvent = e.nativeEvent as TextInputKeyPressEventData & {
206
- shiftKey?: boolean;
207
- };
208
- if (
209
- Platform.OS === 'web' &&
210
- keyEvent.key === 'Enter' &&
211
- !keyEvent.shiftKey
212
- ) {
213
- e.preventDefault();
214
- if (canSend) onSubmit();
215
- }
216
- }}
217
- />
160
+ <View
161
+ style={[
162
+ styles.inputWrapper,
163
+ showVoiceStatusUi && styles.inputWrapperVoiceActive,
164
+ ]}
165
+ >
166
+ {showVoiceStatusUi ? (
167
+ showVoiceRecordingUi ? (
168
+ <VoiceRecordingWaveform
169
+ durationMillis={voiceRecordingDurationMillis}
170
+ metering={voiceMetering}
171
+ />
172
+ ) : (
173
+ <View
174
+ accessible
175
+ accessibilityLabel="Transcribing recorded audio into text"
176
+ style={styles.voiceStatusContent}
177
+ >
178
+ <View style={styles.voiceStatusLabelRow}>
179
+ <View style={[styles.voiceStatusDot, styles.voiceStatusDotBusy]} />
180
+ <Text style={styles.voiceStatusTitle}>Transcribing audio</Text>
181
+ </View>
182
+ <Text style={styles.voiceStatusHint}>
183
+ Converting your latest recording into text.
184
+ </Text>
185
+ </View>
186
+ )
187
+ ) : (
188
+ <>
189
+ <Text
190
+ pointerEvents="none"
191
+ accessibilityElementsHidden
192
+ importantForAccessibility="no-hide-descendants"
193
+ style={[
194
+ styles.inputMeasure,
195
+ {
196
+ width: inputWidth,
197
+ lineHeight: INPUT_TEXT_LINE_HEIGHT,
198
+ paddingVertical: INPUT_TEXT_VERTICAL_PADDING,
199
+ },
200
+ ]}
201
+ onTextLayout={(event: NativeSyntheticEvent<TextLayoutEventData>) => {
202
+ if (inputWidth <= 0) {
203
+ return;
204
+ }
205
+ const lineCount = Math.max(1, event.nativeEvent.lines.length);
206
+ const measuredHeight =
207
+ lineCount * INPUT_TEXT_LINE_HEIGHT + INPUT_TEXT_VERTICAL_PADDING * 2;
208
+ updateInputHeight(measuredHeight);
209
+ }}
210
+ >
211
+ {value.length > 0 ? `${value}\u200b` : ' '}
212
+ </Text>
213
+ <TextInput
214
+ style={[styles.input, { height: inputHeight }]}
215
+ value={value}
216
+ onChangeText={onChangeText}
217
+ keyboardAppearance="dark"
218
+ onLayout={(event) => {
219
+ const nextWidth = Math.floor(event.nativeEvent.layout.width);
220
+ setInputWidth((previousWidth) =>
221
+ previousWidth === nextWidth ? previousWidth : nextWidth
222
+ );
223
+ }}
224
+ onFocus={onFocus}
225
+ placeholder={placeholder}
226
+ placeholderTextColor={colors.textMuted}
227
+ multiline
228
+ scrollEnabled={inputScrollEnabled}
229
+ onKeyPress={(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
230
+ const keyEvent = e.nativeEvent as TextInputKeyPressEventData & {
231
+ shiftKey?: boolean;
232
+ };
233
+ if (
234
+ Platform.OS === 'web' &&
235
+ keyEvent.key === 'Enter' &&
236
+ !keyEvent.shiftKey
237
+ ) {
238
+ e.preventDefault();
239
+ if (canSend) onSubmit();
240
+ }
241
+ }}
242
+ />
243
+ </>
244
+ )}
218
245
  {shouldShowActionButton ? (
219
246
  <View style={styles.actionButtons}>
220
247
  {showVoiceButton || voiceState !== 'idle' ? (
@@ -271,6 +298,7 @@ export function ChatInput({
271
298
  ) : null}
272
299
  </View>
273
300
  </View>
301
+ {footer ? <View style={styles.footer}>{footer}</View> : null}
274
302
  </View>
275
303
  </View>
276
304
  );
@@ -279,20 +307,21 @@ export function ChatInput({
279
307
  const styles = StyleSheet.create({
280
308
  shell: {
281
309
  overflow: 'hidden',
282
- borderTopWidth: StyleSheet.hairlineWidth,
283
- borderTopColor: colors.borderLight,
284
310
  },
285
311
  container: {
286
312
  gap: spacing.xs,
287
313
  paddingHorizontal: spacing.lg,
288
- paddingVertical: spacing.md,
289
- backgroundColor: 'rgba(6, 9, 13, 0.42)',
314
+ paddingTop: spacing.sm,
290
315
  },
291
316
  row: {
292
317
  flexDirection: 'row',
293
318
  alignItems: 'center',
294
319
  gap: spacing.sm,
295
320
  },
321
+ footer: {
322
+ alignItems: 'flex-start',
323
+ marginTop: 2,
324
+ },
296
325
  attachmentList: {
297
326
  maxHeight: 34,
298
327
  },
@@ -332,6 +361,9 @@ const styles = StyleSheet.create({
332
361
  plusBtnPressed: {
333
362
  backgroundColor: colors.bgItem,
334
363
  },
364
+ plusBtnDisabled: {
365
+ opacity: 0.45,
366
+ },
335
367
  inputWrapper: {
336
368
  flex: 1,
337
369
  flexDirection: 'row',
@@ -345,6 +377,10 @@ const styles = StyleSheet.create({
345
377
  minHeight: 40,
346
378
  maxHeight: 120,
347
379
  },
380
+ inputWrapperVoiceActive: {
381
+ minHeight: 58,
382
+ paddingVertical: spacing.sm,
383
+ },
348
384
  input: {
349
385
  flex: 1,
350
386
  color: colors.textPrimary,
@@ -361,6 +397,36 @@ const styles = StyleSheet.create({
361
397
  left: spacing.md,
362
398
  top: spacing.xs,
363
399
  },
400
+ voiceStatusContent: {
401
+ flex: 1,
402
+ gap: 2,
403
+ justifyContent: 'center',
404
+ minHeight: 40,
405
+ },
406
+ voiceStatusLabelRow: {
407
+ alignItems: 'center',
408
+ flexDirection: 'row',
409
+ gap: spacing.xs,
410
+ },
411
+ voiceStatusDot: {
412
+ backgroundColor: colors.error,
413
+ borderRadius: 4,
414
+ height: 8,
415
+ width: 8,
416
+ },
417
+ voiceStatusDotBusy: {
418
+ opacity: 0.82,
419
+ },
420
+ voiceStatusTitle: {
421
+ color: colors.textPrimary,
422
+ fontSize: 13,
423
+ fontWeight: '600',
424
+ },
425
+ voiceStatusHint: {
426
+ color: colors.textMuted,
427
+ fontSize: 12,
428
+ lineHeight: 16,
429
+ },
364
430
  actionButtons: {
365
431
  flexDirection: 'row',
366
432
  alignItems: 'center',