clawdex-mobile 1.3.2 → 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 +101 -541
  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
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clawdex-mobile",
3
- "version": "0.1.0",
3
+ "version": "2.0.0",
4
4
  "private": true,
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "start": "expo start",
8
- "ios": "expo start --ios",
9
- "android": "expo start --android",
8
+ "ios": "expo run:ios",
9
+ "android": "expo run:android",
10
10
  "lint": "eslint App.tsx src --ext .ts,.tsx",
11
11
  "typecheck": "tsc --noEmit",
12
12
  "build": "expo export --platform all --output-dir dist",
@@ -14,36 +14,37 @@
14
14
  "test:watch": "jest --watch"
15
15
  },
16
16
  "dependencies": {
17
- "@expo/metro-runtime": "6.1.2",
17
+ "@expo/metro-runtime": "~55.0.6",
18
18
  "@expo/vector-icons": "15.0.3",
19
- "babel-preset-expo": "54.0.10",
20
- "expo": "54.0.33",
21
- "expo-blur": "15.0.8",
22
- "expo-document-picker": "14.0.8",
23
- "expo-file-system": "19.0.21",
24
- "expo-image-picker": "17.0.10",
25
- "expo-linear-gradient": "15.0.8",
26
- "react": "19.1.0",
27
- "react-dom": "19.1.0",
28
- "react-native": "0.81.5",
29
- "react-native-gesture-handler": "2.28.0",
19
+ "expo": "~55.0.3",
20
+ "expo-audio": "~55.0.8",
21
+ "expo-blur": "~55.0.8",
22
+ "expo-camera": "~55.0.9",
23
+ "expo-document-picker": "~55.0.8",
24
+ "expo-file-system": "~55.0.9",
25
+ "expo-image-picker": "~55.0.9",
26
+ "expo-linear-gradient": "~55.0.8",
27
+ "react": "19.2.0",
28
+ "react-dom": "19.2.0",
29
+ "react-native": "0.83.2",
30
+ "react-native-gesture-handler": "~2.30.0",
30
31
  "react-native-markdown-display": "7.0.2",
31
- "react-native-reanimated": "4.1.1",
32
+ "react-native-reanimated": "4.2.1",
32
33
  "react-native-safe-area-context": "5.6.2",
33
- "react-native-screens": "4.16.0",
34
+ "react-native-screens": "~4.23.0",
34
35
  "react-native-web": "0.21.2",
35
- "react-native-worklets": "0.5.1"
36
+ "react-native-worklets": "0.7.2"
36
37
  },
37
38
  "devDependencies": {
38
- "@types/jest": "^30.0.0",
39
+ "@types/jest": "29.5.14",
39
40
  "@types/node": "^22.10.1",
40
- "@types/react": "~19.1.10",
41
+ "@types/react": "~19.2.10",
41
42
  "@typescript-eslint/eslint-plugin": "^8.18.1",
42
43
  "@typescript-eslint/parser": "^8.18.1",
43
44
  "eslint": "^10.0.0",
44
45
  "globals": "^16.5.0",
45
46
  "jest": "^29.7.0",
46
- "jest-expo": "54.0.17",
47
+ "jest-expo": "~55.0.9",
47
48
  "typescript": "~5.9.2"
48
49
  },
49
50
  "jest": {
@@ -0,0 +1,14 @@
1
+ const { AndroidConfig, withAndroidManifest } = require('@expo/config-plugins');
2
+
3
+ function withAndroidCleartextTraffic(config) {
4
+ return withAndroidManifest(config, (modConfig) => {
5
+ const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(
6
+ modConfig.modResults
7
+ );
8
+
9
+ mainApplication.$['android:usesCleartextTraffic'] = 'true';
10
+ return modConfig;
11
+ });
12
+ }
13
+
14
+ module.exports = withAndroidCleartextTraffic;
@@ -74,18 +74,30 @@ describe('HostBridgeWsClient', () => {
74
74
  });
75
75
  });
76
76
 
77
- it('supports web query token auth fallback when enabled', () => {
78
- if (Platform.OS !== 'web') {
79
- return;
80
- }
81
-
77
+ it('supports query token auth fallback when enabled', () => {
82
78
  const client = new HostBridgeWsClient('http://localhost:8787', {
83
79
  authToken: 'token-xyz',
84
80
  allowQueryTokenAuth: true,
85
81
  });
86
82
  client.connect();
87
83
 
88
- expect(global.WebSocket).toHaveBeenCalledWith('ws://localhost:8787/rpc?token=token-xyz');
84
+ if (Platform.OS === 'web') {
85
+ expect(global.WebSocket).toHaveBeenCalledWith('ws://localhost:8787/rpc?token=token-xyz');
86
+ return;
87
+ }
88
+
89
+ if (Platform.OS === 'android') {
90
+ expect(global.WebSocket).toHaveBeenCalledWith('ws://localhost:8787/rpc?token=token-xyz');
91
+ return;
92
+ }
93
+
94
+ expect(global.WebSocket).toHaveBeenCalledWith(
95
+ 'ws://localhost:8787/rpc?token=token-xyz',
96
+ undefined,
97
+ {
98
+ headers: { Authorization: 'Bearer token-xyz' },
99
+ }
100
+ );
89
101
  });
90
102
 
91
103
  it('onEvent emits rpc notifications', () => {
@@ -222,6 +234,32 @@ describe('HostBridgeWsClient', () => {
222
234
  await expect(waitPromise).resolves.toBeUndefined();
223
235
  });
224
236
 
237
+ it('waitForTurnCompletion resolves from codex event using source parent_thread_id', async () => {
238
+ const client = new HostBridgeWsClient('http://localhost:8787');
239
+ client.connect();
240
+
241
+ const waitPromise = client.waitForTurnCompletion('thr_5', 'turn_5', 100);
242
+ latestMockSocket().simulateMessage(
243
+ JSON.stringify({
244
+ method: 'codex/event/task_complete',
245
+ params: {
246
+ msg: {
247
+ type: 'task_complete',
248
+ source: {
249
+ subagent: {
250
+ thread_spawn: {
251
+ parent_thread_id: 'thr_5',
252
+ },
253
+ },
254
+ },
255
+ },
256
+ },
257
+ })
258
+ );
259
+
260
+ await expect(waitPromise).resolves.toBeUndefined();
261
+ });
262
+
225
263
  it('deduplicates notifications by eventId', () => {
226
264
  const client = new HostBridgeWsClient('http://localhost:8787');
227
265
  const listener = jest.fn();
@@ -82,15 +82,32 @@ function unixSecondsToIso(value: number | undefined): string {
82
82
  return new Date(value * 1000).toISOString();
83
83
  }
84
84
 
85
+ function normalizeLifecycleStatus(value: string | null | undefined): string | null {
86
+ if (!value) {
87
+ return null;
88
+ }
89
+
90
+ const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]/g, '');
91
+ return normalized.length > 0 ? normalized : null;
92
+ }
93
+
85
94
  function mapRawStatus(status: unknown, turns: RawTurn[] | undefined): ChatStatus {
86
95
  const statusRecord = toRecord(status);
87
- const statusType = readString(statusRecord?.type) ?? readString(status);
96
+ const statusType = normalizeLifecycleStatus(
97
+ readString(statusRecord?.type) ?? readString(status)
98
+ );
88
99
  const hasTurns = Array.isArray(turns) && turns.length > 0;
89
100
  const lastTurn = hasTurns ? turns[turns.length - 1] : null;
90
- const lastTurnStatus = readString(lastTurn?.status);
91
- const isIdleLikeStatus = statusType === 'idle' || statusType === 'notLoaded';
92
-
93
- if (lastTurnStatus === 'inProgress') {
101
+ const lastTurnStatus = normalizeLifecycleStatus(readString(lastTurn?.status));
102
+ const isIdleLikeStatus = statusType === 'idle' || statusType === 'notloaded';
103
+
104
+ if (
105
+ lastTurnStatus === 'inprogress' ||
106
+ lastTurnStatus === 'running' ||
107
+ lastTurnStatus === 'active' ||
108
+ lastTurnStatus === 'queued' ||
109
+ lastTurnStatus === 'pending'
110
+ ) {
94
111
  // Some thread/read payloads can return stale turn state while the thread
95
112
  // itself is already idle/notLoaded. Prefer the thread lifecycle in that case.
96
113
  if (isIdleLikeStatus) {
@@ -99,18 +116,41 @@ function mapRawStatus(status: unknown, turns: RawTurn[] | undefined): ChatStatus
99
116
  return 'running';
100
117
  }
101
118
 
102
- if (lastTurnStatus === 'failed' || lastTurnStatus === 'interrupted') {
119
+ if (
120
+ lastTurnStatus === 'failed' ||
121
+ lastTurnStatus === 'interrupted' ||
122
+ lastTurnStatus === 'error' ||
123
+ lastTurnStatus === 'aborted'
124
+ ) {
103
125
  return 'error';
104
126
  }
105
127
 
106
- if (lastTurnStatus === 'completed') {
128
+ if (
129
+ lastTurnStatus === 'completed' ||
130
+ lastTurnStatus === 'complete' ||
131
+ lastTurnStatus === 'success' ||
132
+ lastTurnStatus === 'succeeded'
133
+ ) {
107
134
  return 'complete';
108
135
  }
109
136
 
110
- if (statusType === 'systemError') {
137
+ if (
138
+ statusType === 'systemerror' ||
139
+ statusType === 'error' ||
140
+ statusType === 'failed'
141
+ ) {
111
142
  return 'error';
112
143
  }
113
144
 
145
+ if (
146
+ statusType === 'running' ||
147
+ statusType === 'inprogress' ||
148
+ statusType === 'queued' ||
149
+ statusType === 'pending'
150
+ ) {
151
+ return 'running';
152
+ }
153
+
114
154
  if (statusType === 'active') {
115
155
  // Some backends keep a thread "active" while loaded in memory even when no
116
156
  // turn is running. If there is no in-progress turn, avoid false "working" UI.
@@ -32,6 +32,8 @@ import type {
32
32
  LocalImageInput,
33
33
  UploadAttachmentRequest,
34
34
  UploadAttachmentResponse,
35
+ VoiceTranscribeRequest,
36
+ VoiceTranscribeResponse,
35
37
  ModelOption,
36
38
  ReasoningEffort,
37
39
  ModelReasoningEffortOption,
@@ -477,6 +479,10 @@ export class HostBridgeApiClient {
477
479
  return this.ws.request<UploadAttachmentResponse>('bridge/attachments/upload', body);
478
480
  }
479
481
 
482
+ transcribeVoice(body: VoiceTranscribeRequest): Promise<VoiceTranscribeResponse> {
483
+ return this.ws.request<VoiceTranscribeResponse>('bridge/voice/transcribe', body);
484
+ }
485
+
480
486
  async listModels(includeHidden = false): Promise<ModelOption[]> {
481
487
  const response = await this.ws.request<AppServerModelListResponse>('model/list', {
482
488
  cursor: null,
@@ -310,6 +310,17 @@ export interface RunEvent {
310
310
  detail?: string;
311
311
  }
312
312
 
313
+ export interface VoiceTranscribeRequest {
314
+ dataBase64: string;
315
+ prompt?: string;
316
+ fileName?: string;
317
+ mimeType?: string;
318
+ }
319
+
320
+ export interface VoiceTranscribeResponse {
321
+ text: string;
322
+ }
323
+
313
324
  export interface RpcNotification {
314
325
  method: string;
315
326
  params: Record<string, unknown> | null;
@@ -311,8 +311,12 @@ export class HostBridgeWsClient {
311
311
  await new Promise<void>((resolve, reject) => {
312
312
  const WebSocketCtor = globalThis.WebSocket as unknown as ReactNativeWebSocketConstructor;
313
313
  const socketUrl = this.socketUrl();
314
+ const shouldUseHeaderAuth =
315
+ Boolean(this.authToken) &&
316
+ Platform.OS !== 'web' &&
317
+ !(Platform.OS === 'android' && this.allowQueryTokenAuth);
314
318
  const socket =
315
- this.authToken && Platform.OS !== 'web'
319
+ shouldUseHeaderAuth
316
320
  ? new WebSocketCtor(socketUrl, undefined, {
317
321
  headers: {
318
322
  Authorization: `Bearer ${this.authToken}`,
@@ -640,7 +644,7 @@ export class HostBridgeWsClient {
640
644
  : this.baseUrl.replace('http://', 'ws://');
641
645
  const base = `${wsBase}/rpc`;
642
646
 
643
- if (!this.authToken || Platform.OS !== 'web' || !this.allowQueryTokenAuth) {
647
+ if (!this.authToken || !this.allowQueryTokenAuth) {
644
648
  return base;
645
649
  }
646
650
 
@@ -690,8 +694,8 @@ function toTurnCompletionSnapshot(value: unknown): TurnCompletionSnapshot | null
690
694
  return null;
691
695
  }
692
696
 
693
- const threadId = readString(params.threadId) ?? readString(params.thread_id);
694
697
  const turn = toRecord(params.turn);
698
+ const threadId = extractNotificationThreadId(params, turn);
695
699
  const turnId =
696
700
  readString(turn?.id) ?? readString(params.turnId) ?? readString(params.turn_id);
697
701
  if (!threadId) {
@@ -752,13 +756,7 @@ function toCodexEventSnapshot(
752
756
  return null;
753
757
  }
754
758
 
755
- const threadId =
756
- readString(msg?.thread_id) ??
757
- readString(msg?.threadId) ??
758
- readString(params?.threadId) ??
759
- readString(params?.thread_id) ??
760
- readString(params?.conversationId) ??
761
- readString(msg?.conversation_id);
759
+ const threadId = extractNotificationThreadId(params, msg);
762
760
 
763
761
  return {
764
762
  type,
@@ -766,6 +764,50 @@ function toCodexEventSnapshot(
766
764
  };
767
765
  }
768
766
 
767
+ function extractNotificationThreadId(
768
+ params: Record<string, unknown> | null,
769
+ msgArg?: Record<string, unknown> | null
770
+ ): string | null {
771
+ if (!params && !msgArg) {
772
+ return null;
773
+ }
774
+
775
+ const msg = msgArg ?? toRecord(params?.msg);
776
+ const threadRecord =
777
+ toRecord(params?.thread) ??
778
+ toRecord(params?.threadState) ??
779
+ toRecord(params?.thread_state) ??
780
+ toRecord(msg?.thread);
781
+ const sourceRecord = toRecord(params?.source) ?? toRecord(msg?.source);
782
+ const subagentThreadSpawnRecord = toRecord(
783
+ toRecord(sourceRecord?.subagent)?.thread_spawn
784
+ );
785
+
786
+ return (
787
+ readString(msg?.thread_id) ??
788
+ readString(msg?.threadId) ??
789
+ readString(msg?.conversation_id) ??
790
+ readString(msg?.conversationId) ??
791
+ readString(params?.thread_id) ??
792
+ readString(params?.threadId) ??
793
+ readString(params?.conversation_id) ??
794
+ readString(params?.conversationId) ??
795
+ readString(threadRecord?.id) ??
796
+ readString(threadRecord?.thread_id) ??
797
+ readString(threadRecord?.threadId) ??
798
+ readString(threadRecord?.conversation_id) ??
799
+ readString(threadRecord?.conversationId) ??
800
+ readString(sourceRecord?.thread_id) ??
801
+ readString(sourceRecord?.threadId) ??
802
+ readString(sourceRecord?.conversation_id) ??
803
+ readString(sourceRecord?.conversationId) ??
804
+ readString(sourceRecord?.parent_thread_id) ??
805
+ readString(sourceRecord?.parentThreadId) ??
806
+ readString(subagentThreadSpawnRecord?.parent_thread_id) ??
807
+ null
808
+ );
809
+ }
810
+
769
811
  function normalizeCodexEventType(value: string | null): string | null {
770
812
  if (!value) {
771
813
  return null;
@@ -0,0 +1,105 @@
1
+ const SUPPORTED_PROTOCOLS = new Set(['http:', 'https:', 'ws:', 'wss:']);
2
+
3
+ export function normalizeBridgeUrlInput(value: string): string | null {
4
+ if (typeof value !== 'string') {
5
+ return null;
6
+ }
7
+
8
+ const trimmed = value.trim();
9
+ if (!trimmed) {
10
+ return null;
11
+ }
12
+
13
+ let parsed: URL;
14
+ try {
15
+ parsed = new URL(trimmed);
16
+ } catch {
17
+ return null;
18
+ }
19
+
20
+ if (!SUPPORTED_PROTOCOLS.has(parsed.protocol)) {
21
+ return null;
22
+ }
23
+
24
+ if (!parsed.hostname || parsed.username || parsed.password) {
25
+ return null;
26
+ }
27
+
28
+ const normalizedProtocol =
29
+ parsed.protocol === 'ws:' ? 'http:' : parsed.protocol === 'wss:' ? 'https:' : parsed.protocol;
30
+ const normalizedPath = parsed.pathname.replace(/\/+$/, '');
31
+
32
+ parsed.protocol = normalizedProtocol;
33
+ parsed.pathname = normalizedPath || '';
34
+ parsed.search = '';
35
+ parsed.hash = '';
36
+ parsed.username = '';
37
+ parsed.password = '';
38
+
39
+ return parsed.toString().replace(/\/$/, '');
40
+ }
41
+
42
+ export function isInsecureRemoteUrl(url: string): boolean {
43
+ try {
44
+ const parsed = new URL(url);
45
+ if (parsed.protocol !== 'http:') {
46
+ return false;
47
+ }
48
+
49
+ return !isLikelyPrivateHost(parsed.hostname);
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ export function toBridgeHealthUrl(baseUrl: string): string {
56
+ return `${baseUrl.replace(/\/$/, '')}/health`;
57
+ }
58
+
59
+ function isLikelyPrivateHost(hostname: string): boolean {
60
+ const normalized = hostname.trim().toLowerCase();
61
+ const host =
62
+ normalized.startsWith('[') && normalized.endsWith(']')
63
+ ? normalized.slice(1, -1)
64
+ : normalized;
65
+ if (!host) {
66
+ return false;
67
+ }
68
+
69
+ if (
70
+ host === 'localhost' ||
71
+ host === '127.0.0.1' ||
72
+ host === '::1' ||
73
+ host.endsWith('.local')
74
+ ) {
75
+ return true;
76
+ }
77
+
78
+ if (host.includes(':')) {
79
+ return (
80
+ host.startsWith('fc') ||
81
+ host.startsWith('fd') ||
82
+ host.startsWith('fe80:')
83
+ );
84
+ }
85
+
86
+ const octets = host.split('.');
87
+ if (octets.length !== 4) {
88
+ return false;
89
+ }
90
+
91
+ const [firstStr, secondStr] = octets;
92
+ const first = Number.parseInt(firstStr, 10);
93
+ const second = Number.parseInt(secondStr, 10);
94
+ if (!Number.isInteger(first) || !Number.isInteger(second)) {
95
+ return false;
96
+ }
97
+
98
+ return (
99
+ first === 10 ||
100
+ (first === 172 && second >= 16 && second <= 31) ||
101
+ (first === 192 && second === 168) ||
102
+ (first === 169 && second === 254) ||
103
+ (first === 100 && second >= 64 && second <= 127)
104
+ );
105
+ }
@@ -1,6 +1,7 @@
1
1
  import { Ionicons } from '@expo/vector-icons';
2
+ import { BlurView } from 'expo-blur';
2
3
  import { useEffect, useState } from 'react';
3
- import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
4
+ import { ActivityIndicator, Platform, StyleSheet, Text, View } from 'react-native';
4
5
 
5
6
  import { colors, spacing, typography } from '../theme';
6
7
 
@@ -46,29 +47,47 @@ export function ActivityBar({ title, detail, tone }: ActivityBarProps) {
46
47
  const text = `${title}${suffix}${dots}`;
47
48
 
48
49
  return (
49
- <View style={styles.container}>
50
- {tone === 'running' ? (
51
- <ActivityIndicator size="small" color={color} />
52
- ) : (
53
- <Ionicons name={ICON_BY_TONE[tone]} size={14} color={color} />
54
- )}
55
- <Text style={styles.text} numberOfLines={1}>
56
- {text}
57
- </Text>
58
- </View>
50
+ <BlurView
51
+ intensity={42}
52
+ tint={Platform.OS === 'ios' ? 'systemUltraThinMaterialDark' : 'dark'}
53
+ blurMethod="dimezisBlurViewSdk31Plus"
54
+ style={styles.container}
55
+ >
56
+ <View style={styles.content}>
57
+ {tone === 'running' ? (
58
+ <ActivityIndicator size="small" color={color} />
59
+ ) : (
60
+ <Ionicons name={ICON_BY_TONE[tone]} size={13} color={color} />
61
+ )}
62
+ <Text style={styles.text} numberOfLines={1}>
63
+ {text}
64
+ </Text>
65
+ </View>
66
+ </BlurView>
59
67
  );
60
68
  }
61
69
 
62
70
  const styles = StyleSheet.create({
63
71
  container: {
72
+ borderRadius: 10,
73
+ overflow: 'hidden',
74
+ borderWidth: StyleSheet.hairlineWidth,
75
+ borderColor: 'rgba(255, 255, 255, 0.12)',
76
+ backgroundColor: 'rgba(18, 22, 28, 0.16)',
77
+ marginHorizontal: spacing.lg,
78
+ marginBottom: spacing.xs / 2,
79
+ },
80
+ content: {
64
81
  flexDirection: 'row',
65
82
  alignItems: 'center',
66
83
  gap: spacing.xs,
67
- marginHorizontal: spacing.lg,
68
- paddingVertical: spacing.xs,
84
+ paddingHorizontal: spacing.sm + 2,
85
+ paddingVertical: 3,
69
86
  },
70
87
  text: {
71
88
  ...typography.caption,
89
+ fontSize: 11,
90
+ lineHeight: 15,
72
91
  fontWeight: '600',
73
92
  color: colors.textPrimary,
74
93
  flex: 1,
@@ -1,5 +1,6 @@
1
1
  import { Ionicons } from '@expo/vector-icons';
2
- import { Pressable, SafeAreaView, StyleSheet, Text, View } from 'react-native';
2
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
3
+ import { SafeAreaView } from 'react-native-safe-area-context';
3
4
  import { BrandMark } from './BrandMark';
4
5
  import { colors, spacing, typography } from '../theme';
5
6
 
@@ -22,7 +23,7 @@ export function ChatHeader({
22
23
 
23
24
  return (
24
25
  <View style={styles.headerContainer}>
25
- <SafeAreaView>
26
+ <SafeAreaView edges={['top', 'left', 'right']}>
26
27
  <View style={styles.header}>
27
28
  <Pressable onPress={onOpenDrawer} hitSlop={8} style={styles.menuBtn}>
28
29
  <Ionicons name="menu" size={22} color={colors.textPrimary} />