@assistant-ui/mcp-docs-server 0.1.21 → 0.1.23

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 (74) hide show
  1. package/.docs/organized/code-examples/waterfall.md +801 -0
  2. package/.docs/organized/code-examples/with-ag-ui.md +38 -26
  3. package/.docs/organized/code-examples/with-ai-sdk-v6.md +38 -28
  4. package/.docs/organized/code-examples/with-artifacts.md +467 -0
  5. package/.docs/organized/code-examples/with-assistant-transport.md +31 -24
  6. package/.docs/organized/code-examples/with-chain-of-thought.md +607 -0
  7. package/.docs/organized/code-examples/with-cloud-standalone.md +675 -0
  8. package/.docs/organized/code-examples/with-cloud.md +34 -27
  9. package/.docs/organized/code-examples/with-custom-thread-list.md +34 -27
  10. package/.docs/organized/code-examples/with-elevenlabs-scribe.md +41 -30
  11. package/.docs/organized/code-examples/with-expo.md +2031 -0
  12. package/.docs/organized/code-examples/with-external-store.md +32 -25
  13. package/.docs/organized/code-examples/with-ffmpeg.md +31 -27
  14. package/.docs/organized/code-examples/with-langgraph.md +96 -38
  15. package/.docs/organized/code-examples/with-parent-id-grouping.md +32 -25
  16. package/.docs/organized/code-examples/with-react-hook-form.md +63 -58
  17. package/.docs/organized/code-examples/with-react-router.md +38 -30
  18. package/.docs/organized/code-examples/with-store.md +16 -24
  19. package/.docs/organized/code-examples/with-tanstack.md +36 -26
  20. package/.docs/organized/code-examples/with-tap-runtime.md +10 -24
  21. package/.docs/raw/docs/(docs)/cli.mdx +13 -6
  22. package/.docs/raw/docs/(docs)/guides/attachments.mdx +26 -3
  23. package/.docs/raw/docs/(docs)/guides/chain-of-thought.mdx +162 -0
  24. package/.docs/raw/docs/(docs)/guides/context-api.mdx +53 -52
  25. package/.docs/raw/docs/(docs)/guides/dictation.mdx +0 -2
  26. package/.docs/raw/docs/(docs)/guides/message-timing.mdx +169 -0
  27. package/.docs/raw/docs/(docs)/guides/quoting.mdx +327 -0
  28. package/.docs/raw/docs/(docs)/guides/speech.mdx +0 -1
  29. package/.docs/raw/docs/(docs)/index.mdx +13 -3
  30. package/.docs/raw/docs/(docs)/installation.mdx +8 -2
  31. package/.docs/raw/docs/(docs)/llm.mdx +10 -8
  32. package/.docs/raw/docs/(reference)/api-reference/primitives/action-bar-more.mdx +1 -1
  33. package/.docs/raw/docs/(reference)/api-reference/primitives/action-bar.mdx +2 -2
  34. package/.docs/raw/docs/(reference)/api-reference/primitives/assistant-if.mdx +27 -27
  35. package/.docs/raw/docs/(reference)/api-reference/primitives/composer.mdx +60 -0
  36. package/.docs/raw/docs/(reference)/api-reference/primitives/message-part.mdx +78 -4
  37. package/.docs/raw/docs/(reference)/api-reference/primitives/message.mdx +32 -0
  38. package/.docs/raw/docs/(reference)/api-reference/primitives/selection-toolbar.mdx +61 -0
  39. package/.docs/raw/docs/(reference)/api-reference/primitives/thread.mdx +1 -1
  40. package/.docs/raw/docs/(reference)/legacy/styled/assistant-modal.mdx +1 -6
  41. package/.docs/raw/docs/(reference)/legacy/styled/decomposition.mdx +2 -2
  42. package/.docs/raw/docs/(reference)/legacy/styled/markdown.mdx +1 -6
  43. package/.docs/raw/docs/(reference)/legacy/styled/thread.mdx +1 -5
  44. package/.docs/raw/docs/(reference)/migrations/v0-12.mdx +17 -17
  45. package/.docs/raw/docs/cloud/ai-sdk-assistant-ui.mdx +205 -0
  46. package/.docs/raw/docs/cloud/ai-sdk.mdx +292 -0
  47. package/.docs/raw/docs/cloud/authorization.mdx +178 -79
  48. package/.docs/raw/docs/cloud/{persistence/langgraph.mdx → langgraph.mdx} +2 -2
  49. package/.docs/raw/docs/cloud/overview.mdx +29 -39
  50. package/.docs/raw/docs/react-native/adapters.mdx +118 -0
  51. package/.docs/raw/docs/react-native/custom-backend.mdx +210 -0
  52. package/.docs/raw/docs/react-native/hooks.mdx +364 -0
  53. package/.docs/raw/docs/react-native/index.mdx +332 -0
  54. package/.docs/raw/docs/react-native/primitives.mdx +653 -0
  55. package/.docs/raw/docs/runtimes/ai-sdk/v6.mdx +7 -15
  56. package/.docs/raw/docs/runtimes/assistant-transport.mdx +103 -0
  57. package/.docs/raw/docs/runtimes/custom/external-store.mdx +25 -2
  58. package/.docs/raw/docs/runtimes/data-stream.mdx +1 -3
  59. package/.docs/raw/docs/runtimes/langgraph/index.mdx +113 -9
  60. package/.docs/raw/docs/runtimes/pick-a-runtime.mdx +1 -4
  61. package/.docs/raw/docs/ui/attachment.mdx +4 -2
  62. package/.docs/raw/docs/ui/message-timing.mdx +92 -0
  63. package/.docs/raw/docs/ui/part-grouping.mdx +1 -1
  64. package/.docs/raw/docs/ui/reasoning.mdx +4 -4
  65. package/.docs/raw/docs/ui/scrollbar.mdx +2 -2
  66. package/.docs/raw/docs/ui/syntax-highlighting.mdx +55 -50
  67. package/.docs/raw/docs/ui/thread.mdx +16 -9
  68. package/dist/index.d.ts +1 -1
  69. package/dist/index.d.ts.map +1 -1
  70. package/package.json +3 -3
  71. package/src/tools/tests/integration.test.ts +2 -2
  72. package/src/tools/tests/json-parsing.test.ts +1 -1
  73. package/src/tools/tests/mcp-protocol.test.ts +1 -3
  74. package/.docs/raw/docs/cloud/persistence/ai-sdk.mdx +0 -108
@@ -0,0 +1,2031 @@
1
+ # Example: with-expo
2
+
3
+ ## .vscode/extensions.json
4
+
5
+ ```json
6
+ { "recommendations": ["expo.vscode-expo-tools"] }
7
+
8
+ ```
9
+
10
+ ## .vscode/settings.json
11
+
12
+ ```json
13
+ {
14
+ "editor.codeActionsOnSave": {
15
+ "source.fixAll": "explicit",
16
+ "source.organizeImports": "explicit",
17
+ "source.sortMembers": "explicit"
18
+ }
19
+ }
20
+
21
+ ```
22
+
23
+ ## adapters/openai-chat-adapter.ts
24
+
25
+ ```typescript
26
+ import type { ChatModelAdapter } from "@assistant-ui/react-native";
27
+
28
+ export type OpenAIModelConfig = {
29
+ apiKey: string;
30
+ model?: string;
31
+ baseURL?: string;
32
+ /** Custom fetch implementation — pass `fetch` from `expo/fetch` for streaming support */
33
+ fetch?: typeof globalThis.fetch;
34
+ };
35
+
36
+ type OpenAIMessage = {
37
+ role: string;
38
+ content: string | any[] | null;
39
+ tool_calls?: {
40
+ id: string;
41
+ type: string;
42
+ function: { name: string; arguments: string };
43
+ }[];
44
+ tool_call_id?: string;
45
+ };
46
+
47
+ type ToolCallAccumulator = Record<
48
+ number,
49
+ { id: string; name: string; arguments: string }
50
+ >;
51
+
52
+ export function createOpenAIChatModelAdapter(
53
+ config: OpenAIModelConfig,
54
+ ): ChatModelAdapter {
55
+ const {
56
+ apiKey,
57
+ model = "gpt-4o-mini",
58
+ baseURL = "https://api.openai.com/v1",
59
+ fetch: customFetch = globalThis.fetch,
60
+ } = config;
61
+
62
+ const callOpenAI = async (
63
+ messages: OpenAIMessage[],
64
+ openAITools: any[] | undefined,
65
+ abortSignal: AbortSignal,
66
+ ) => {
67
+ const response = await customFetch(`${baseURL}/chat/completions`, {
68
+ method: "POST",
69
+ headers: {
70
+ "Content-Type": "application/json",
71
+ Authorization: `Bearer ${apiKey}`,
72
+ },
73
+ body: JSON.stringify({
74
+ model,
75
+ messages,
76
+ stream: true,
77
+ ...(openAITools ? { tools: openAITools } : {}),
78
+ }),
79
+ signal: abortSignal,
80
+ });
81
+
82
+ if (!response.ok) {
83
+ const body = await response.text().catch(() => "");
84
+ throw new Error(`OpenAI API error: ${response.status} ${body}`);
85
+ }
86
+
87
+ return response;
88
+ };
89
+
90
+ const streamResponse = async function* (
91
+ response: Response,
92
+ onUpdate: (text: string, toolCalls: ToolCallAccumulator) => any,
93
+ ) {
94
+ const reader = response.body?.getReader();
95
+ if (!reader) {
96
+ const json = await response.json();
97
+ const choice = json.choices?.[0]?.message;
98
+ return {
99
+ text: (choice?.content as string) ?? "",
100
+ toolCalls: {} as ToolCallAccumulator,
101
+ rawToolCalls: choice?.tool_calls,
102
+ };
103
+ }
104
+
105
+ const decoder = new TextDecoder();
106
+ let fullText = "";
107
+ const toolCalls: ToolCallAccumulator = {};
108
+
109
+ try {
110
+ while (true) {
111
+ const { done, value } = await reader.read();
112
+ if (done) break;
113
+
114
+ const chunk = decoder.decode(value, { stream: true });
115
+ for (const line of chunk.split("\n")) {
116
+ if (!line.startsWith("data: ")) continue;
117
+ const data = line.slice(6);
118
+ if (data === "[DONE]") continue;
119
+
120
+ try {
121
+ const delta = JSON.parse(data).choices?.[0]?.delta;
122
+ if (!delta) continue;
123
+
124
+ if (delta.content) fullText += delta.content;
125
+ if (delta.tool_calls) {
126
+ for (const tc of delta.tool_calls) {
127
+ if (!toolCalls[tc.index]) {
128
+ toolCalls[tc.index] = {
129
+ id: tc.id ?? "",
130
+ name: tc.function?.name ?? "",
131
+ arguments: "",
132
+ };
133
+ }
134
+ if (tc.id) toolCalls[tc.index].id = tc.id;
135
+ if (tc.function?.name)
136
+ toolCalls[tc.index].name = tc.function.name;
137
+ if (tc.function?.arguments)
138
+ toolCalls[tc.index].arguments += tc.function.arguments;
139
+ }
140
+ }
141
+
142
+ yield* onUpdate(fullText, toolCalls);
143
+ } catch {
144
+ // skip invalid JSON
145
+ }
146
+ }
147
+ }
148
+ } finally {
149
+ reader.releaseLock();
150
+ }
151
+
152
+ return { text: fullText, toolCalls };
153
+ };
154
+
155
+ return {
156
+ async *run({ messages, context, abortSignal }) {
157
+ const tools = context.tools;
158
+
159
+ // Convert messages to OpenAI format
160
+ const openAIMessages: OpenAIMessage[] = messages
161
+ .filter((m) => m.role !== "system")
162
+ .flatMap((m) => {
163
+ if (m.role === "user") {
164
+ const textParts = m.content.filter((p) => p.type === "text");
165
+ const text = textParts
166
+ .map((p) => ("text" in p ? p.text : ""))
167
+ .join("\n");
168
+
169
+ // Check for image attachments
170
+ const imageAttachments = (m.attachments ?? []).flatMap((a) =>
171
+ (a.content ?? []).filter((c: any) => c.type === "image"),
172
+ );
173
+
174
+ if (imageAttachments.length > 0) {
175
+ const content: any[] = [];
176
+ if (text) content.push({ type: "text", text });
177
+ for (const img of imageAttachments) {
178
+ content.push({
179
+ type: "image_url",
180
+ image_url: { url: (img as any).image },
181
+ });
182
+ }
183
+ return [{ role: "user", content }];
184
+ }
185
+
186
+ return [{ role: "user", content: text }];
187
+ }
188
+ if (m.role === "assistant") {
189
+ const result: OpenAIMessage[] = [];
190
+ const textParts = m.content.filter((p) => p.type === "text");
191
+ const toolCallParts = m.content.filter(
192
+ (p) => p.type === "tool-call",
193
+ );
194
+
195
+ if (toolCallParts.length > 0) {
196
+ result.push({
197
+ role: "assistant",
198
+ content:
199
+ textParts.length > 0
200
+ ? textParts
201
+ .map((p) => ("text" in p ? p.text : ""))
202
+ .join("\n")
203
+ : null,
204
+ tool_calls: toolCallParts.map((p: any) => ({
205
+ id: p.toolCallId,
206
+ type: "function",
207
+ function: {
208
+ name: p.toolName,
209
+ arguments: JSON.stringify(p.args),
210
+ },
211
+ })),
212
+ });
213
+ for (const tc of toolCallParts) {
214
+ if ((tc as any).result !== undefined) {
215
+ result.push({
216
+ role: "tool",
217
+ content: JSON.stringify((tc as any).result),
218
+ tool_call_id: (tc as any).toolCallId,
219
+ });
220
+ }
221
+ }
222
+ } else if (textParts.length > 0) {
223
+ result.push({
224
+ role: "assistant",
225
+ content: textParts
226
+ .map((p) => ("text" in p ? p.text : ""))
227
+ .join("\n"),
228
+ });
229
+ }
230
+
231
+ return result;
232
+ }
233
+ return [];
234
+ });
235
+
236
+ const openAITools =
237
+ tools && Object.keys(tools).length > 0
238
+ ? Object.entries(tools).map(([name, t]) => ({
239
+ type: "function" as const,
240
+ function: {
241
+ name,
242
+ description: (t as any).description ?? "",
243
+ parameters: (t as any).parameters ?? {},
244
+ },
245
+ }))
246
+ : undefined;
247
+
248
+ // Tool execution loop — keep calling OpenAI until we get a text response
249
+ const maxToolRounds = 5;
250
+ const priorParts: any[] = []; // accumulate tool-call parts across rounds
251
+
252
+ for (let round = 0; round <= maxToolRounds; round++) {
253
+ const response = await callOpenAI(
254
+ openAIMessages,
255
+ openAITools,
256
+ abortSignal,
257
+ );
258
+
259
+ let lastText = "";
260
+ const gen = streamResponse(response, function* (text, toolCalls) {
261
+ lastText = text;
262
+ const content: any[] = [...priorParts];
263
+ if (text) content.push({ type: "text" as const, text });
264
+ for (const tc of Object.values(toolCalls)) {
265
+ let args = {};
266
+ try {
267
+ args = JSON.parse(tc.arguments);
268
+ } catch {
269
+ // still streaming
270
+ }
271
+ content.push({
272
+ type: "tool-call" as const,
273
+ toolCallId: tc.id,
274
+ toolName: tc.name,
275
+ args,
276
+ });
277
+ }
278
+ if (content.length > 0) yield { content };
279
+ });
280
+
281
+ // Consume the stream
282
+ let streamResult: any;
283
+ while (true) {
284
+ const { value, done } = await gen.next();
285
+ if (done) {
286
+ streamResult = value;
287
+ break;
288
+ }
289
+ yield value;
290
+ }
291
+
292
+ const { toolCalls } = (streamResult as {
293
+ toolCalls: ToolCallAccumulator;
294
+ }) ?? { toolCalls: {} };
295
+ const pendingToolCalls = Object.values(toolCalls) as {
296
+ id: string;
297
+ name: string;
298
+ arguments: string;
299
+ }[];
300
+
301
+ // No tool calls — done
302
+ if (pendingToolCalls.length === 0) break;
303
+
304
+ // Execute tools and add results to messages for next round
305
+ openAIMessages.push({
306
+ role: "assistant",
307
+ content: lastText || null,
308
+ tool_calls: pendingToolCalls.map((tc) => ({
309
+ id: tc.id,
310
+ type: "function",
311
+ function: { name: tc.name, arguments: tc.arguments },
312
+ })),
313
+ });
314
+
315
+ const executedToolCalls: any[] = [];
316
+ for (const tc of pendingToolCalls) {
317
+ const args = JSON.parse(tc.arguments);
318
+ const toolDef = tools?.[tc.name];
319
+ let result: any;
320
+ if (toolDef?.execute) {
321
+ result = await (toolDef as any).execute(args);
322
+ }
323
+
324
+ executedToolCalls.push({
325
+ type: "tool-call" as const,
326
+ toolCallId: tc.id,
327
+ toolName: tc.name,
328
+ args,
329
+ result,
330
+ });
331
+
332
+ // Yield with all prior parts + executed tool calls so far
333
+ yield { content: [...priorParts, ...executedToolCalls] };
334
+
335
+ openAIMessages.push({
336
+ role: "tool",
337
+ content: JSON.stringify(result),
338
+ tool_call_id: tc.id,
339
+ });
340
+ }
341
+
342
+ // Add executed tool calls to prior parts for next round
343
+ priorParts.push(...executedToolCalls);
344
+
345
+ // Next iteration will call OpenAI with tool results
346
+ }
347
+ },
348
+ };
349
+ }
350
+
351
+ ```
352
+
353
+ ## app.json
354
+
355
+ ```json
356
+ {
357
+ "expo": {
358
+ "name": "with-expo",
359
+ "slug": "with-expo",
360
+ "version": "0.0.0",
361
+ "orientation": "portrait",
362
+ "icon": "./assets/images/icon.png",
363
+ "scheme": "withexpo",
364
+ "userInterfaceStyle": "automatic",
365
+ "newArchEnabled": true,
366
+ "ios": {
367
+ "supportsTablet": true,
368
+ "bundleIdentifier": "com.assistant-ui.with-expo"
369
+ },
370
+ "android": {
371
+ "adaptiveIcon": {
372
+ "backgroundColor": "#E6F4FE",
373
+ "foregroundImage": "./assets/images/android-icon-foreground.png",
374
+ "backgroundImage": "./assets/images/android-icon-background.png",
375
+ "monochromeImage": "./assets/images/android-icon-monochrome.png"
376
+ },
377
+ "edgeToEdgeEnabled": true,
378
+ "predictiveBackGestureEnabled": false
379
+ },
380
+ "web": {
381
+ "output": "static",
382
+ "favicon": "./assets/images/favicon.png"
383
+ },
384
+ "plugins": [
385
+ "expo-router",
386
+ [
387
+ "expo-splash-screen",
388
+ {
389
+ "image": "./assets/images/splash-icon.png",
390
+ "imageWidth": 200,
391
+ "resizeMode": "contain",
392
+ "backgroundColor": "#ffffff",
393
+ "dark": {
394
+ "backgroundColor": "#000000"
395
+ }
396
+ }
397
+ ]
398
+ ],
399
+ "experiments": {
400
+ "typedRoutes": true,
401
+ "reactCompiler": true
402
+ }
403
+ }
404
+ }
405
+
406
+ ```
407
+
408
+ ## app/_layout.tsx
409
+
410
+ ```tsx
411
+ import {
412
+ DarkTheme,
413
+ DefaultTheme,
414
+ ThemeProvider,
415
+ } from "@react-navigation/native";
416
+ import { Drawer } from "expo-router/drawer";
417
+ import { StatusBar } from "expo-status-bar";
418
+ import "react-native-reanimated";
419
+ import { Pressable, useColorScheme } from "react-native";
420
+ import { Ionicons } from "@expo/vector-icons";
421
+ import { GestureHandlerRootView } from "react-native-gesture-handler";
422
+
423
+ import {
424
+ AssistantProvider,
425
+ useAssistantRuntime,
426
+ } from "@assistant-ui/react-native";
427
+ import { useAppRuntime } from "@/hooks/use-app-runtime";
428
+ import { ThreadListDrawer } from "@/components/thread-list/ThreadListDrawer";
429
+ import { WeatherTool } from "@/components/assistant-ui/tools";
430
+
431
+ function NewChatButton() {
432
+ const runtime = useAssistantRuntime();
433
+ const colorScheme = useColorScheme();
434
+ const isDark = colorScheme === "dark";
435
+
436
+ return (
437
+ <Pressable
438
+ onPress={() => {
439
+ runtime.threads.switchToNewThread();
440
+ }}
441
+ style={{ marginRight: 16 }}
442
+ >
443
+ <Ionicons
444
+ name="create-outline"
445
+ size={24}
446
+ color={isDark ? "#ffffff" : "#000000"}
447
+ />
448
+ </Pressable>
449
+ );
450
+ }
451
+
452
+ function DrawerLayout() {
453
+ const colorScheme = useColorScheme();
454
+
455
+ return (
456
+ <ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
457
+ <Drawer
458
+ drawerContent={(props) => <ThreadListDrawer {...props} />}
459
+ screenOptions={{
460
+ headerRight: () => <NewChatButton />,
461
+ drawerType: "front",
462
+ swipeEnabled: true,
463
+ drawerStyle: { backgroundColor: "transparent" },
464
+ }}
465
+ >
466
+ <Drawer.Screen name="index" options={{ title: "Chat" }} />
467
+ </Drawer>
468
+ <StatusBar style="auto" />
469
+ </ThemeProvider>
470
+ );
471
+ }
472
+
473
+ export default function RootLayout() {
474
+ const runtime = useAppRuntime();
475
+
476
+ return (
477
+ <GestureHandlerRootView style={{ flex: 1 }}>
478
+ <AssistantProvider runtime={runtime}>
479
+ <WeatherTool />
480
+ <DrawerLayout />
481
+ </AssistantProvider>
482
+ </GestureHandlerRootView>
483
+ );
484
+ }
485
+
486
+ ```
487
+
488
+ ## app/index.tsx
489
+
490
+ ```tsx
491
+ import { Thread } from "@/components/assistant-ui/thread";
492
+
493
+ export default function ChatPage() {
494
+ return <Thread />;
495
+ }
496
+
497
+ ```
498
+
499
+ ## components/assistant-ui/composer.tsx
500
+
501
+ ```tsx
502
+ import {
503
+ View,
504
+ TextInput,
505
+ Pressable,
506
+ Image,
507
+ StyleSheet,
508
+ useColorScheme,
509
+ } from "react-native";
510
+ import { Ionicons } from "@expo/vector-icons";
511
+ import * as ImagePicker from "expo-image-picker";
512
+ import {
513
+ useAui,
514
+ useAuiState,
515
+ useComposerSend,
516
+ useComposerCancel,
517
+ useComposerAddAttachment,
518
+ ComposerAttachments,
519
+ AttachmentRoot,
520
+ AttachmentRemove,
521
+ } from "@assistant-ui/react-native";
522
+
523
+ function AttachmentPreview() {
524
+ const attachment = useAuiState((s) => s.attachment);
525
+ if (!attachment) return null;
526
+
527
+ // Find image content for preview URI
528
+ const imageContent = attachment.content?.find((c: any) => c.type === "image");
529
+ const uri = (imageContent as any)?.image;
530
+
531
+ return (
532
+ <AttachmentRoot style={styles.attachmentItem}>
533
+ {uri ? <Image source={{ uri }} style={styles.attachmentImage} /> : null}
534
+ <AttachmentRemove style={styles.attachmentRemoveButton}>
535
+ <Ionicons name="close-circle" size={20} color="#ff453a" />
536
+ </AttachmentRemove>
537
+ </AttachmentRoot>
538
+ );
539
+ }
540
+
541
+ const attachmentComponents = { Attachment: AttachmentPreview };
542
+
543
+ export function Composer() {
544
+ const colorScheme = useColorScheme();
545
+ const isDark = colorScheme === "dark";
546
+
547
+ const aui = useAui();
548
+ const text = useAuiState((s) => s.composer.text);
549
+ const attachmentsCount = useAuiState((s) => s.composer.attachments.length);
550
+ const { send, canSend } = useComposerSend();
551
+ const { cancel, canCancel } = useComposerCancel();
552
+ const { addAttachment } = useComposerAddAttachment();
553
+
554
+ const pickImage = async () => {
555
+ const result = await ImagePicker.launchImageLibraryAsync({
556
+ mediaTypes: ["images"],
557
+ allowsMultipleSelection: true,
558
+ quality: 0.8,
559
+ base64: true,
560
+ });
561
+
562
+ if (result.canceled) return;
563
+
564
+ for (const asset of result.assets) {
565
+ // Force JPEG mime type — iOS may report HEIC which OpenAI doesn't support
566
+ const dataUrl = `data:image/jpeg;base64,${asset.base64}`;
567
+
568
+ await addAttachment({
569
+ name: asset.fileName ?? "image.jpg",
570
+ contentType: "image/jpeg",
571
+ type: "image",
572
+ content: [{ type: "image", image: dataUrl }],
573
+ });
574
+ }
575
+ };
576
+
577
+ return (
578
+ <View
579
+ style={[
580
+ styles.container,
581
+ {
582
+ backgroundColor: isDark
583
+ ? "rgba(28, 28, 30, 0.8)"
584
+ : "rgba(242, 242, 247, 0.8)",
585
+ },
586
+ ]}
587
+ >
588
+ {attachmentsCount > 0 && (
589
+ <View style={styles.attachmentsList}>
590
+ <ComposerAttachments components={attachmentComponents} />
591
+ </View>
592
+ )}
593
+ <View
594
+ style={[
595
+ styles.inputWrapper,
596
+ {
597
+ backgroundColor: isDark ? "#1c1c1e" : "#ffffff",
598
+ borderColor: isDark ? "#3a3a3c" : "#e5e5ea",
599
+ },
600
+ ]}
601
+ >
602
+ <Pressable
603
+ style={styles.attachButton}
604
+ onPress={pickImage}
605
+ disabled={canCancel}
606
+ >
607
+ <Ionicons
608
+ name="add-circle-outline"
609
+ size={24}
610
+ color={isDark ? "#8e8e93" : "#6e6e73"}
611
+ />
612
+ </Pressable>
613
+ <TextInput
614
+ style={[styles.input, { color: isDark ? "#ffffff" : "#000000" }]}
615
+ placeholder="Message..."
616
+ placeholderTextColor="#8e8e93"
617
+ value={text}
618
+ onChangeText={(newText) => aui.composer().setText(newText)}
619
+ multiline
620
+ maxLength={4000}
621
+ editable={!canCancel}
622
+ />
623
+ {canCancel ? (
624
+ <Pressable
625
+ style={[styles.button, styles.stopButton]}
626
+ onPress={cancel}
627
+ >
628
+ <View style={styles.stopIcon} />
629
+ </Pressable>
630
+ ) : (
631
+ <Pressable
632
+ style={[
633
+ styles.button,
634
+ styles.sendButton,
635
+ {
636
+ backgroundColor: canSend
637
+ ? isDark
638
+ ? "#0a84ff"
639
+ : "#007aff"
640
+ : isDark
641
+ ? "#3a3a3c"
642
+ : "#e5e5ea",
643
+ },
644
+ ]}
645
+ onPress={send}
646
+ disabled={!canSend}
647
+ >
648
+ <Ionicons
649
+ name="arrow-up"
650
+ size={20}
651
+ color={canSend ? "#ffffff" : "#8e8e93"}
652
+ />
653
+ </Pressable>
654
+ )}
655
+ </View>
656
+ </View>
657
+ );
658
+ }
659
+
660
+ const styles = StyleSheet.create({
661
+ container: {
662
+ paddingHorizontal: 16,
663
+ paddingTop: 12,
664
+ paddingBottom: 8,
665
+ },
666
+ attachmentsList: {
667
+ flexDirection: "row",
668
+ flexWrap: "wrap",
669
+ gap: 8,
670
+ paddingBottom: 8,
671
+ },
672
+ attachmentItem: {
673
+ position: "relative",
674
+ },
675
+ attachmentImage: {
676
+ width: 60,
677
+ height: 60,
678
+ borderRadius: 8,
679
+ },
680
+ attachmentRemoveButton: {
681
+ position: "absolute",
682
+ top: -6,
683
+ right: -6,
684
+ },
685
+ inputWrapper: {
686
+ flexDirection: "row",
687
+ alignItems: "flex-end",
688
+ borderRadius: 24,
689
+ borderWidth: 1,
690
+ paddingLeft: 6,
691
+ paddingRight: 6,
692
+ paddingVertical: 6,
693
+ minHeight: 48,
694
+ },
695
+ attachButton: {
696
+ width: 34,
697
+ height: 34,
698
+ justifyContent: "center",
699
+ alignItems: "center",
700
+ },
701
+ input: {
702
+ flex: 1,
703
+ fontSize: 16,
704
+ lineHeight: 22,
705
+ maxHeight: 120,
706
+ paddingVertical: 6,
707
+ letterSpacing: -0.2,
708
+ },
709
+ button: {
710
+ width: 34,
711
+ height: 34,
712
+ borderRadius: 17,
713
+ justifyContent: "center",
714
+ alignItems: "center",
715
+ marginLeft: 8,
716
+ },
717
+ sendButton: {},
718
+ stopButton: {
719
+ backgroundColor: "#ff453a",
720
+ },
721
+ stopIcon: {
722
+ width: 12,
723
+ height: 12,
724
+ borderRadius: 2,
725
+ backgroundColor: "#ffffff",
726
+ },
727
+ });
728
+
729
+ ```
730
+
731
+ ## components/assistant-ui/message-action-bar.tsx
732
+
733
+ ```tsx
734
+ import { Pressable, View, StyleSheet, useColorScheme } from "react-native";
735
+ import { Ionicons } from "@expo/vector-icons";
736
+ import {
737
+ useActionBarCopy,
738
+ useActionBarReload,
739
+ } from "@assistant-ui/react-native";
740
+
741
+ export function MessageActionBar() {
742
+ const colorScheme = useColorScheme();
743
+ const isDark = colorScheme === "dark";
744
+ const iconColor = isDark ? "#8e8e93" : "#6e6e73";
745
+
746
+ const { copy, isCopied } = useActionBarCopy();
747
+ const { reload } = useActionBarReload();
748
+
749
+ return (
750
+ <View style={styles.container}>
751
+ <Pressable style={styles.button} onPress={copy}>
752
+ <Ionicons
753
+ name={isCopied ? "checkmark" : "copy-outline"}
754
+ size={16}
755
+ color={isCopied ? "#34c759" : iconColor}
756
+ />
757
+ </Pressable>
758
+ <Pressable style={styles.button} onPress={reload}>
759
+ <Ionicons name="refresh-outline" size={16} color={iconColor} />
760
+ </Pressable>
761
+ </View>
762
+ );
763
+ }
764
+
765
+ const styles = StyleSheet.create({
766
+ container: {
767
+ flexDirection: "row",
768
+ gap: 4,
769
+ marginTop: 4,
770
+ },
771
+ button: {
772
+ padding: 6,
773
+ borderRadius: 8,
774
+ },
775
+ });
776
+
777
+ ```
778
+
779
+ ## components/assistant-ui/message-branch-picker.tsx
780
+
781
+ ```tsx
782
+ import { Pressable, View, StyleSheet, useColorScheme } from "react-native";
783
+ import { Ionicons } from "@expo/vector-icons";
784
+ import { ThemedText } from "@/components/themed-text";
785
+ import { useMessageBranching } from "@assistant-ui/react-native";
786
+
787
+ export function MessageBranchPicker() {
788
+ const { branchNumber, branchCount, goToPrev, goToNext } =
789
+ useMessageBranching();
790
+
791
+ const colorScheme = useColorScheme();
792
+ const isDark = colorScheme === "dark";
793
+ const iconColor = isDark ? "#8e8e93" : "#6e6e73";
794
+
795
+ if (branchCount <= 1) return null;
796
+
797
+ return (
798
+ <View style={styles.container}>
799
+ <Pressable
800
+ style={styles.button}
801
+ onPress={goToPrev}
802
+ disabled={branchNumber <= 1}
803
+ >
804
+ <Ionicons
805
+ name="chevron-back"
806
+ size={14}
807
+ color={
808
+ branchNumber <= 1 ? (isDark ? "#3a3a3c" : "#d1d1d6") : iconColor
809
+ }
810
+ />
811
+ </Pressable>
812
+ <ThemedText style={styles.label} lightColor="#6e6e73" darkColor="#8e8e93">
813
+ {branchNumber} / {branchCount}
814
+ </ThemedText>
815
+ <Pressable
816
+ style={styles.button}
817
+ onPress={goToNext}
818
+ disabled={branchNumber >= branchCount}
819
+ >
820
+ <Ionicons
821
+ name="chevron-forward"
822
+ size={14}
823
+ color={
824
+ branchNumber >= branchCount
825
+ ? isDark
826
+ ? "#3a3a3c"
827
+ : "#d1d1d6"
828
+ : iconColor
829
+ }
830
+ />
831
+ </Pressable>
832
+ </View>
833
+ );
834
+ }
835
+
836
+ const styles = StyleSheet.create({
837
+ container: {
838
+ flexDirection: "row",
839
+ alignItems: "center",
840
+ gap: 2,
841
+ },
842
+ button: {
843
+ padding: 4,
844
+ borderRadius: 6,
845
+ },
846
+ label: {
847
+ fontSize: 12,
848
+ fontVariant: ["tabular-nums"],
849
+ },
850
+ });
851
+
852
+ ```
853
+
854
+ ## components/assistant-ui/message.tsx
855
+
856
+ ```tsx
857
+ import { View, Image, StyleSheet, useColorScheme } from "react-native";
858
+ import { ThemedText } from "@/components/themed-text";
859
+ import {
860
+ useAuiState,
861
+ MessageContent,
862
+ MessageAttachments,
863
+ } from "@assistant-ui/react-native";
864
+ import { MessageActionBar } from "./message-action-bar";
865
+ import { MessageBranchPicker } from "./message-branch-picker";
866
+
867
+ function MessageError() {
868
+ const error = useAuiState((s) => {
869
+ const status = s.message.status;
870
+ if (status?.type === "incomplete" && status.reason === "error") {
871
+ return status.error ?? "An error occurred";
872
+ }
873
+ return null;
874
+ });
875
+
876
+ if (!error) return null;
877
+
878
+ return (
879
+ <View style={styles.errorContainer}>
880
+ <ThemedText
881
+ style={styles.errorText}
882
+ lightColor="#ff453a"
883
+ darkColor="#ff6961"
884
+ >
885
+ {typeof error === "string" ? error : "An error occurred"}
886
+ </ThemedText>
887
+ </View>
888
+ );
889
+ }
890
+
891
+ function TextPart({ part }: { part: { type: "text"; text: string } }) {
892
+ const role = useAuiState((s) => s.message.role);
893
+ if (role === "user") {
894
+ return <ThemedText style={styles.userText}>{part.text}</ThemedText>;
895
+ }
896
+ return (
897
+ <ThemedText
898
+ style={styles.assistantText}
899
+ lightColor="#000000"
900
+ darkColor="#ffffff"
901
+ >
902
+ {part.text}
903
+ </ThemedText>
904
+ );
905
+ }
906
+
907
+ function MessageImageAttachment() {
908
+ const attachment = useAuiState((s) => s.attachment);
909
+ if (!attachment) return null;
910
+
911
+ const imageContent = attachment.content?.find((c: any) => c.type === "image");
912
+ const uri = (imageContent as any)?.image;
913
+ if (!uri) return null;
914
+
915
+ return <Image source={{ uri }} style={styles.messageImage} />;
916
+ }
917
+
918
+ const messageAttachmentComponents = { Attachment: MessageImageAttachment };
919
+
920
+ export function MessageBubble() {
921
+ const colorScheme = useColorScheme();
922
+ const isDark = colorScheme === "dark";
923
+ const role = useAuiState((s) => s.message.role);
924
+ const isRunning = useAuiState((s) => s.message.status?.type === "running");
925
+ const isUser = role === "user";
926
+
927
+ if (isUser) {
928
+ return (
929
+ <View style={[styles.container, styles.userContainer]}>
930
+ <MessageAttachments components={messageAttachmentComponents} />
931
+ <View
932
+ style={[
933
+ styles.bubble,
934
+ styles.userBubble,
935
+ { backgroundColor: isDark ? "#0a84ff" : "#007aff" },
936
+ ]}
937
+ >
938
+ <MessageContent renderText={({ part }) => <TextPart part={part} />} />
939
+ </View>
940
+ <MessageBranchPicker />
941
+ </View>
942
+ );
943
+ }
944
+
945
+ return (
946
+ <View style={[styles.container, styles.assistantContainer]}>
947
+ <View
948
+ style={[
949
+ styles.bubble,
950
+ styles.assistantBubble,
951
+ {
952
+ backgroundColor: isDark
953
+ ? "rgba(44, 44, 46, 0.8)"
954
+ : "rgba(229, 229, 234, 0.8)",
955
+ },
956
+ ]}
957
+ >
958
+ <MessageContent renderText={({ part }) => <TextPart part={part} />} />
959
+ <MessageError />
960
+ </View>
961
+ {!isRunning && (
962
+ <View style={styles.actionsRow}>
963
+ <MessageBranchPicker />
964
+ <MessageActionBar />
965
+ </View>
966
+ )}
967
+ </View>
968
+ );
969
+ }
970
+
971
+ const styles = StyleSheet.create({
972
+ container: {
973
+ paddingHorizontal: 16,
974
+ paddingVertical: 6,
975
+ },
976
+ userContainer: {
977
+ alignItems: "flex-end",
978
+ },
979
+ assistantContainer: {
980
+ alignItems: "flex-start",
981
+ },
982
+ bubble: {
983
+ maxWidth: "85%",
984
+ paddingHorizontal: 16,
985
+ paddingVertical: 12,
986
+ borderRadius: 20,
987
+ },
988
+ userBubble: {
989
+ borderBottomRightRadius: 6,
990
+ },
991
+ actionsRow: {
992
+ flexDirection: "row",
993
+ alignItems: "center",
994
+ gap: 4,
995
+ marginTop: 4,
996
+ },
997
+ assistantBubble: {
998
+ borderBottomLeftRadius: 6,
999
+ },
1000
+ errorContainer: {
1001
+ paddingTop: 4,
1002
+ },
1003
+ errorText: {
1004
+ fontSize: 14,
1005
+ lineHeight: 20,
1006
+ },
1007
+ messageImage: {
1008
+ width: 200,
1009
+ height: 200,
1010
+ borderRadius: 12,
1011
+ marginBottom: 4,
1012
+ },
1013
+ userText: {
1014
+ fontSize: 16,
1015
+ lineHeight: 22,
1016
+ color: "#ffffff",
1017
+ letterSpacing: -0.2,
1018
+ },
1019
+ assistantText: {
1020
+ fontSize: 16,
1021
+ lineHeight: 24,
1022
+ letterSpacing: -0.2,
1023
+ },
1024
+ });
1025
+
1026
+ ```
1027
+
1028
+ ## components/assistant-ui/thread.tsx
1029
+
1030
+ ```tsx
1031
+ import {
1032
+ View,
1033
+ Text,
1034
+ Pressable,
1035
+ StyleSheet,
1036
+ KeyboardAvoidingView,
1037
+ Platform,
1038
+ useColorScheme,
1039
+ } from "react-native";
1040
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
1041
+ import { MessageBubble } from "./message";
1042
+ import { Composer } from "./composer";
1043
+ import {
1044
+ ThreadMessages,
1045
+ useThreadIsEmpty,
1046
+ useAui,
1047
+ } from "@assistant-ui/react-native";
1048
+
1049
+ function SuggestionChip({ title, prompt }: { title: string; prompt: string }) {
1050
+ const colorScheme = useColorScheme();
1051
+ const isDark = colorScheme === "dark";
1052
+ const aui = useAui();
1053
+
1054
+ return (
1055
+ <Pressable
1056
+ onPress={() => aui.thread().append(prompt)}
1057
+ style={[
1058
+ styles.suggestionChip,
1059
+ {
1060
+ backgroundColor: isDark
1061
+ ? "rgba(44, 44, 46, 0.8)"
1062
+ : "rgba(229, 229, 234, 0.8)",
1063
+ },
1064
+ ]}
1065
+ >
1066
+ <Text
1067
+ style={[
1068
+ styles.suggestionText,
1069
+ { color: isDark ? "#ffffff" : "#000000" },
1070
+ ]}
1071
+ >
1072
+ {title}
1073
+ </Text>
1074
+ </Pressable>
1075
+ );
1076
+ }
1077
+
1078
+ const defaultSuggestions = [
1079
+ {
1080
+ title: "What's the weather in Tokyo?",
1081
+ prompt: "What's the weather in Tokyo?",
1082
+ },
1083
+ { title: "Tell me a joke", prompt: "Tell me a joke" },
1084
+ { title: "Help me write an email", prompt: "Help me write an email" },
1085
+ ];
1086
+
1087
+ function Suggestions() {
1088
+ return (
1089
+ <View style={styles.suggestionsContainer}>
1090
+ {defaultSuggestions.map((s, i) => (
1091
+ <SuggestionChip key={i} title={s.title} prompt={s.prompt} />
1092
+ ))}
1093
+ </View>
1094
+ );
1095
+ }
1096
+
1097
+ function EmptyState() {
1098
+ const colorScheme = useColorScheme();
1099
+ const isDark = colorScheme === "dark";
1100
+
1101
+ return (
1102
+ <View
1103
+ style={[
1104
+ styles.emptyContainer,
1105
+ { backgroundColor: isDark ? "#000000" : "#ffffff" },
1106
+ ]}
1107
+ >
1108
+ <View style={styles.emptyIconContainer}>
1109
+ <Text style={styles.emptyIcon}>💭</Text>
1110
+ </View>
1111
+ <Text
1112
+ style={[styles.emptyTitle, { color: isDark ? "#ffffff" : "#000000" }]}
1113
+ >
1114
+ How can I help?
1115
+ </Text>
1116
+ <Text
1117
+ style={[
1118
+ styles.emptySubtitle,
1119
+ { color: isDark ? "#8e8e93" : "#6e6e73" },
1120
+ ]}
1121
+ >
1122
+ Send a message to start chatting
1123
+ </Text>
1124
+ <Suggestions />
1125
+ </View>
1126
+ );
1127
+ }
1128
+
1129
+ const renderMessage = () => <MessageBubble />;
1130
+
1131
+ function ChatMessages() {
1132
+ const isEmpty = useThreadIsEmpty();
1133
+
1134
+ if (isEmpty) {
1135
+ return <EmptyState />;
1136
+ }
1137
+
1138
+ return (
1139
+ <ThreadMessages
1140
+ renderMessage={renderMessage}
1141
+ contentContainerStyle={styles.messageList}
1142
+ showsVerticalScrollIndicator={false}
1143
+ />
1144
+ );
1145
+ }
1146
+
1147
+ export function Thread() {
1148
+ const insets = useSafeAreaInsets();
1149
+ const colorScheme = useColorScheme();
1150
+ const isDark = colorScheme === "dark";
1151
+ return (
1152
+ <View
1153
+ style={[
1154
+ styles.container,
1155
+ { backgroundColor: isDark ? "#000000" : "#ffffff" },
1156
+ ]}
1157
+ >
1158
+ <KeyboardAvoidingView
1159
+ style={styles.keyboardAvoid}
1160
+ behavior={Platform.OS === "ios" ? "padding" : "height"}
1161
+ >
1162
+ <View style={styles.messagesContainer}>
1163
+ <ChatMessages />
1164
+ </View>
1165
+ <View style={{ paddingBottom: insets.bottom }}>
1166
+ <Composer />
1167
+ </View>
1168
+ </KeyboardAvoidingView>
1169
+ </View>
1170
+ );
1171
+ }
1172
+
1173
+ const styles = StyleSheet.create({
1174
+ container: {
1175
+ flex: 1,
1176
+ },
1177
+ keyboardAvoid: {
1178
+ flex: 1,
1179
+ },
1180
+ messagesContainer: {
1181
+ flex: 1,
1182
+ },
1183
+ messageList: {
1184
+ paddingVertical: 20,
1185
+ paddingHorizontal: 4,
1186
+ },
1187
+ emptyContainer: {
1188
+ flex: 1,
1189
+ justifyContent: "center",
1190
+ alignItems: "center",
1191
+ padding: 40,
1192
+ },
1193
+ emptyIconContainer: {
1194
+ width: 72,
1195
+ height: 72,
1196
+ borderRadius: 36,
1197
+ backgroundColor: "rgba(0, 122, 255, 0.1)",
1198
+ justifyContent: "center",
1199
+ alignItems: "center",
1200
+ marginBottom: 20,
1201
+ },
1202
+ emptyIcon: {
1203
+ fontSize: 32,
1204
+ },
1205
+ emptyTitle: {
1206
+ fontSize: 22,
1207
+ fontWeight: "600",
1208
+ marginBottom: 8,
1209
+ letterSpacing: -0.4,
1210
+ },
1211
+ emptySubtitle: {
1212
+ fontSize: 15,
1213
+ textAlign: "center",
1214
+ letterSpacing: -0.2,
1215
+ },
1216
+ suggestionsContainer: {
1217
+ flexDirection: "row",
1218
+ flexWrap: "wrap",
1219
+ justifyContent: "center",
1220
+ gap: 8,
1221
+ marginTop: 20,
1222
+ paddingHorizontal: 16,
1223
+ },
1224
+ suggestionChip: {
1225
+ paddingHorizontal: 16,
1226
+ paddingVertical: 10,
1227
+ borderRadius: 18,
1228
+ },
1229
+ suggestionText: {
1230
+ fontSize: 14,
1231
+ letterSpacing: -0.2,
1232
+ },
1233
+ });
1234
+
1235
+ ```
1236
+
1237
+ ## components/assistant-ui/tools.tsx
1238
+
1239
+ ```tsx
1240
+ import { View, Text, StyleSheet, useColorScheme } from "react-native";
1241
+ import {
1242
+ makeAssistantTool,
1243
+ type ToolCallMessagePartProps,
1244
+ } from "@assistant-ui/react-native";
1245
+
1246
+ const WeatherToolUI = (
1247
+ props: ToolCallMessagePartProps<{ city: string }, { temperature: number }>,
1248
+ ) => {
1249
+ const colorScheme = useColorScheme();
1250
+ const isDark = colorScheme === "dark";
1251
+
1252
+ if (props.status?.type === "running") {
1253
+ return (
1254
+ <View
1255
+ style={[
1256
+ styles.card,
1257
+ { backgroundColor: isDark ? "#1c1c1e" : "#f2f2f7" },
1258
+ ]}
1259
+ >
1260
+ <Text style={[styles.label, { color: isDark ? "#8e8e93" : "#6e6e73" }]}>
1261
+ Looking up weather for {props.args.city}...
1262
+ </Text>
1263
+ </View>
1264
+ );
1265
+ }
1266
+
1267
+ return (
1268
+ <View
1269
+ style={[styles.card, { backgroundColor: isDark ? "#1c1c1e" : "#f2f2f7" }]}
1270
+ >
1271
+ <Text style={[styles.city, { color: isDark ? "#ffffff" : "#000000" }]}>
1272
+ {props.args.city}
1273
+ </Text>
1274
+ <Text style={styles.temp}>{props.result?.temperature ?? "—"}°F</Text>
1275
+ <Text style={[styles.label, { color: isDark ? "#8e8e93" : "#6e6e73" }]}>
1276
+ Current Weather
1277
+ </Text>
1278
+ </View>
1279
+ );
1280
+ };
1281
+
1282
+ export const WeatherTool = makeAssistantTool({
1283
+ toolName: "get_weather",
1284
+ description: "Get the current weather for a city",
1285
+ parameters: {
1286
+ type: "object",
1287
+ properties: {
1288
+ city: { type: "string", description: "The city name" },
1289
+ },
1290
+ required: ["city"],
1291
+ },
1292
+ execute: async ({ city }) => {
1293
+ // Simulated weather API — use city to vary seed
1294
+ await new Promise((r) => setTimeout(r, 1000));
1295
+ const seed = city.length;
1296
+ const temperature = Math.round(50 + ((seed * 17) % 40));
1297
+ return { temperature };
1298
+ },
1299
+ render: WeatherToolUI,
1300
+ });
1301
+
1302
+ const styles = StyleSheet.create({
1303
+ card: {
1304
+ padding: 16,
1305
+ borderRadius: 12,
1306
+ marginVertical: 4,
1307
+ gap: 4,
1308
+ },
1309
+ city: {
1310
+ fontSize: 15,
1311
+ fontWeight: "600",
1312
+ },
1313
+ temp: {
1314
+ fontSize: 32,
1315
+ fontWeight: "700",
1316
+ color: "#007aff",
1317
+ },
1318
+ label: {
1319
+ fontSize: 13,
1320
+ },
1321
+ });
1322
+
1323
+ ```
1324
+
1325
+ ## components/themed-text.tsx
1326
+
1327
+ ```tsx
1328
+ import { StyleSheet, Text, type TextProps } from "react-native";
1329
+
1330
+ import { useThemeColor } from "@/hooks/use-theme-color";
1331
+
1332
+ export type ThemedTextProps = TextProps & {
1333
+ lightColor?: string;
1334
+ darkColor?: string;
1335
+ type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link";
1336
+ };
1337
+
1338
+ export function ThemedText({
1339
+ style,
1340
+ lightColor,
1341
+ darkColor,
1342
+ type = "default",
1343
+ ...rest
1344
+ }: ThemedTextProps) {
1345
+ const color = useThemeColor({ light: lightColor, dark: darkColor }, "text");
1346
+
1347
+ return (
1348
+ <Text
1349
+ style={[
1350
+ { color },
1351
+ type === "default" ? styles.default : undefined,
1352
+ type === "title" ? styles.title : undefined,
1353
+ type === "defaultSemiBold" ? styles.defaultSemiBold : undefined,
1354
+ type === "subtitle" ? styles.subtitle : undefined,
1355
+ type === "link" ? styles.link : undefined,
1356
+ style,
1357
+ ]}
1358
+ {...rest}
1359
+ />
1360
+ );
1361
+ }
1362
+
1363
+ const styles = StyleSheet.create({
1364
+ default: {
1365
+ fontSize: 16,
1366
+ lineHeight: 24,
1367
+ },
1368
+ defaultSemiBold: {
1369
+ fontSize: 16,
1370
+ lineHeight: 24,
1371
+ fontWeight: "600",
1372
+ },
1373
+ title: {
1374
+ fontSize: 32,
1375
+ fontWeight: "bold",
1376
+ lineHeight: 32,
1377
+ },
1378
+ subtitle: {
1379
+ fontSize: 20,
1380
+ fontWeight: "bold",
1381
+ },
1382
+ link: {
1383
+ lineHeight: 30,
1384
+ fontSize: 16,
1385
+ color: "#0a7ea4",
1386
+ },
1387
+ });
1388
+
1389
+ ```
1390
+
1391
+ ## components/themed-view.tsx
1392
+
1393
+ ```tsx
1394
+ import { View, type ViewProps } from "react-native";
1395
+
1396
+ import { useThemeColor } from "@/hooks/use-theme-color";
1397
+
1398
+ export type ThemedViewProps = ViewProps & {
1399
+ lightColor?: string;
1400
+ darkColor?: string;
1401
+ };
1402
+
1403
+ export function ThemedView({
1404
+ style,
1405
+ lightColor,
1406
+ darkColor,
1407
+ ...otherProps
1408
+ }: ThemedViewProps) {
1409
+ const backgroundColor = useThemeColor(
1410
+ { light: lightColor, dark: darkColor },
1411
+ "background",
1412
+ );
1413
+
1414
+ return <View style={[{ backgroundColor }, style]} {...otherProps} />;
1415
+ }
1416
+
1417
+ ```
1418
+
1419
+ ## components/thread-list/ThreadListDrawer.tsx
1420
+
1421
+ ```tsx
1422
+ import { FlatList, View, StyleSheet, useColorScheme } from "react-native";
1423
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
1424
+ import { useAssistantRuntime, useAuiState } from "@assistant-ui/react-native";
1425
+ import { ThreadListItem } from "./ThreadListItem";
1426
+ import type { DrawerContentComponentProps } from "@react-navigation/drawer";
1427
+
1428
+ export function ThreadListDrawer({ navigation }: DrawerContentComponentProps) {
1429
+ const runtime = useAssistantRuntime();
1430
+ const threadIds = useAuiState((s) => s.threads.threadIds);
1431
+ const mainThreadId = useAuiState((s) => s.threads.mainThreadId);
1432
+ const threadItems = useAuiState((s) => s.threads.threadItems);
1433
+ const insets = useSafeAreaInsets();
1434
+ const isDark = useColorScheme() === "dark";
1435
+
1436
+ return (
1437
+ <View
1438
+ style={[
1439
+ styles.container,
1440
+ {
1441
+ backgroundColor: isDark
1442
+ ? "rgba(28, 28, 30, 0.85)"
1443
+ : "rgba(242, 242, 247, 0.85)",
1444
+ paddingTop: insets.top,
1445
+ },
1446
+ ]}
1447
+ >
1448
+ <FlatList
1449
+ data={threadIds}
1450
+ keyExtractor={(item) => item}
1451
+ renderItem={({ item: threadId }) => {
1452
+ const threadItem = threadItems.find((t) => t.id === threadId);
1453
+ return (
1454
+ <ThreadListItem
1455
+ title={threadItem?.title ?? "New Chat"}
1456
+ isActive={threadId === mainThreadId}
1457
+ onPress={() => {
1458
+ runtime.threads.switchToThread(threadId);
1459
+ navigation.closeDrawer();
1460
+ }}
1461
+ />
1462
+ );
1463
+ }}
1464
+ contentContainerStyle={styles.list}
1465
+ showsVerticalScrollIndicator={false}
1466
+ />
1467
+ </View>
1468
+ );
1469
+ }
1470
+
1471
+ const styles = StyleSheet.create({
1472
+ container: {
1473
+ flex: 1,
1474
+ },
1475
+ list: {
1476
+ paddingVertical: 8,
1477
+ },
1478
+ });
1479
+
1480
+ ```
1481
+
1482
+ ## components/thread-list/ThreadListItem.tsx
1483
+
1484
+ ```tsx
1485
+ import {
1486
+ Pressable,
1487
+ Text,
1488
+ View,
1489
+ StyleSheet,
1490
+ useColorScheme,
1491
+ } from "react-native";
1492
+
1493
+ type ThreadListItemProps = {
1494
+ title: string;
1495
+ isActive: boolean;
1496
+ onPress: () => void;
1497
+ };
1498
+
1499
+ export function ThreadListItem({
1500
+ title,
1501
+ isActive,
1502
+ onPress,
1503
+ }: ThreadListItemProps) {
1504
+ const isDark = useColorScheme() === "dark";
1505
+
1506
+ const content = (
1507
+ <View style={styles.row}>
1508
+ {isActive && (
1509
+ <View style={[styles.indicator, { backgroundColor: "#007AFF" }]} />
1510
+ )}
1511
+ <Text
1512
+ numberOfLines={1}
1513
+ style={[
1514
+ styles.title,
1515
+ { color: isDark ? "#ffffff" : "#000000" },
1516
+ isActive && styles.titleActive,
1517
+ ]}
1518
+ >
1519
+ {title}
1520
+ </Text>
1521
+ </View>
1522
+ );
1523
+
1524
+ if (isActive) {
1525
+ return (
1526
+ <Pressable onPress={onPress} style={styles.itemOuter}>
1527
+ <View
1528
+ style={[
1529
+ styles.glassItem,
1530
+ {
1531
+ backgroundColor: isDark
1532
+ ? "rgba(44, 44, 46, 0.6)"
1533
+ : "rgba(209, 209, 214, 0.6)",
1534
+ },
1535
+ ]}
1536
+ >
1537
+ {content}
1538
+ </View>
1539
+ </Pressable>
1540
+ );
1541
+ }
1542
+
1543
+ return (
1544
+ <Pressable
1545
+ onPress={onPress}
1546
+ style={({ pressed }) => [
1547
+ styles.itemOuter,
1548
+ styles.itemPadding,
1549
+ pressed && {
1550
+ backgroundColor: isDark ? "#3a3a3c" : "#d1d1d6",
1551
+ borderRadius: 10,
1552
+ },
1553
+ ]}
1554
+ >
1555
+ {content}
1556
+ </Pressable>
1557
+ );
1558
+ }
1559
+
1560
+ const styles = StyleSheet.create({
1561
+ itemOuter: {
1562
+ marginHorizontal: 8,
1563
+ marginVertical: 2,
1564
+ },
1565
+ itemPadding: {
1566
+ paddingVertical: 12,
1567
+ paddingHorizontal: 16,
1568
+ },
1569
+ glassItem: {
1570
+ paddingVertical: 12,
1571
+ paddingHorizontal: 16,
1572
+ borderRadius: 10,
1573
+ overflow: "hidden",
1574
+ },
1575
+ row: {
1576
+ flexDirection: "row",
1577
+ alignItems: "center",
1578
+ },
1579
+ indicator: {
1580
+ width: 4,
1581
+ height: 20,
1582
+ borderRadius: 2,
1583
+ marginRight: 10,
1584
+ },
1585
+ title: {
1586
+ fontSize: 15,
1587
+ flex: 1,
1588
+ },
1589
+ titleActive: {
1590
+ fontWeight: "600",
1591
+ },
1592
+ });
1593
+
1594
+ ```
1595
+
1596
+ ## components/ui/icon-symbol.ios.tsx
1597
+
1598
+ ```tsx
1599
+ import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols";
1600
+ import { StyleProp, ViewStyle } from "react-native";
1601
+
1602
+ export function IconSymbol({
1603
+ name,
1604
+ size = 24,
1605
+ color,
1606
+ style,
1607
+ weight = "regular",
1608
+ }: {
1609
+ name: SymbolViewProps["name"];
1610
+ size?: number;
1611
+ color: string;
1612
+ style?: StyleProp<ViewStyle>;
1613
+ weight?: SymbolWeight;
1614
+ }) {
1615
+ return (
1616
+ <SymbolView
1617
+ weight={weight}
1618
+ tintColor={color}
1619
+ resizeMode="scaleAspectFit"
1620
+ name={name}
1621
+ style={[
1622
+ {
1623
+ width: size,
1624
+ height: size,
1625
+ },
1626
+ style,
1627
+ ]}
1628
+ />
1629
+ );
1630
+ }
1631
+
1632
+ ```
1633
+
1634
+ ## components/ui/icon-symbol.tsx
1635
+
1636
+ ```tsx
1637
+ // Fallback for using MaterialIcons on Android and web.
1638
+
1639
+ import MaterialIcons from "@expo/vector-icons/MaterialIcons";
1640
+ import { SymbolWeight, SymbolViewProps } from "expo-symbols";
1641
+ import { ComponentProps } from "react";
1642
+ import { OpaqueColorValue, type StyleProp, type TextStyle } from "react-native";
1643
+
1644
+ type IconMapping = Record<
1645
+ SymbolViewProps["name"],
1646
+ ComponentProps<typeof MaterialIcons>["name"]
1647
+ >;
1648
+ type IconSymbolName = keyof typeof MAPPING;
1649
+
1650
+ /**
1651
+ * Add your SF Symbols to Material Icons mappings here.
1652
+ * - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
1653
+ * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
1654
+ */
1655
+ const MAPPING = {
1656
+ "house.fill": "home",
1657
+ "paperplane.fill": "send",
1658
+ "chevron.left.forwardslash.chevron.right": "code",
1659
+ "chevron.right": "chevron-right",
1660
+ } as IconMapping;
1661
+
1662
+ /**
1663
+ * An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
1664
+ * This ensures a consistent look across platforms, and optimal resource usage.
1665
+ * Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
1666
+ */
1667
+ export function IconSymbol({
1668
+ name,
1669
+ size = 24,
1670
+ color,
1671
+ style,
1672
+ }: {
1673
+ name: IconSymbolName;
1674
+ size?: number;
1675
+ color: string | OpaqueColorValue;
1676
+ style?: StyleProp<TextStyle>;
1677
+ weight?: SymbolWeight;
1678
+ }) {
1679
+ return (
1680
+ <MaterialIcons
1681
+ color={color}
1682
+ size={size}
1683
+ name={MAPPING[name]}
1684
+ style={style}
1685
+ />
1686
+ );
1687
+ }
1688
+
1689
+ ```
1690
+
1691
+ ## constants/theme.ts
1692
+
1693
+ ```typescript
1694
+ /**
1695
+ * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
1696
+ * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
1697
+ */
1698
+
1699
+ import { Platform } from "react-native";
1700
+
1701
+ const tintColorLight = "#0a7ea4";
1702
+ const tintColorDark = "#fff";
1703
+
1704
+ export const Colors = {
1705
+ light: {
1706
+ text: "#11181C",
1707
+ background: "#fff",
1708
+ tint: tintColorLight,
1709
+ icon: "#687076",
1710
+ tabIconDefault: "#687076",
1711
+ tabIconSelected: tintColorLight,
1712
+ },
1713
+ dark: {
1714
+ text: "#ECEDEE",
1715
+ background: "#151718",
1716
+ tint: tintColorDark,
1717
+ icon: "#9BA1A6",
1718
+ tabIconDefault: "#9BA1A6",
1719
+ tabIconSelected: tintColorDark,
1720
+ },
1721
+ };
1722
+
1723
+ export const Fonts = Platform.select({
1724
+ ios: {
1725
+ /** iOS `UIFontDescriptorSystemDesignDefault` */
1726
+ sans: "system-ui",
1727
+ /** iOS `UIFontDescriptorSystemDesignSerif` */
1728
+ serif: "ui-serif",
1729
+ /** iOS `UIFontDescriptorSystemDesignRounded` */
1730
+ rounded: "ui-rounded",
1731
+ /** iOS `UIFontDescriptorSystemDesignMonospaced` */
1732
+ mono: "ui-monospace",
1733
+ },
1734
+ default: {
1735
+ sans: "normal",
1736
+ serif: "serif",
1737
+ rounded: "normal",
1738
+ mono: "monospace",
1739
+ },
1740
+ web: {
1741
+ sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
1742
+ serif: "Georgia, 'Times New Roman', serif",
1743
+ rounded:
1744
+ "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
1745
+ mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
1746
+ },
1747
+ });
1748
+
1749
+ ```
1750
+
1751
+ ## eslint.config.js
1752
+
1753
+ ```javascript
1754
+ // https://docs.expo.dev/guides/using-eslint/
1755
+ const { defineConfig } = require("eslint/config");
1756
+ const expoConfig = require("eslint-config-expo/flat");
1757
+
1758
+ module.exports = defineConfig([
1759
+ expoConfig,
1760
+ {
1761
+ ignores: ["dist/*"],
1762
+ },
1763
+ ]);
1764
+
1765
+ ```
1766
+
1767
+ ## hooks/use-app-runtime.ts
1768
+
1769
+ ```typescript
1770
+ import { useMemo } from "react";
1771
+ import { fetch } from "expo/fetch";
1772
+ import {
1773
+ useLocalRuntime,
1774
+ createSimpleTitleAdapter,
1775
+ SimpleImageAttachmentAdapter,
1776
+ } from "@assistant-ui/react-native";
1777
+ import { createOpenAIChatModelAdapter } from "@/adapters/openai-chat-adapter";
1778
+
1779
+ export function useAppRuntime() {
1780
+ const chatModel = useMemo(
1781
+ () =>
1782
+ createOpenAIChatModelAdapter({
1783
+ apiKey: process.env.EXPO_PUBLIC_OPENAI_API_KEY ?? "",
1784
+ model: "gpt-4o-mini",
1785
+ fetch,
1786
+ }),
1787
+ [],
1788
+ );
1789
+
1790
+ const titleGenerator = useMemo(() => createSimpleTitleAdapter(), []);
1791
+
1792
+ return useLocalRuntime(chatModel, {
1793
+ titleGenerator,
1794
+ adapters: {
1795
+ attachments: new SimpleImageAttachmentAdapter(),
1796
+ },
1797
+ });
1798
+ }
1799
+
1800
+ ```
1801
+
1802
+ ## hooks/use-color-scheme.ts
1803
+
1804
+ ```typescript
1805
+ export { useColorScheme } from "react-native";
1806
+
1807
+ ```
1808
+
1809
+ ## hooks/use-color-scheme.web.ts
1810
+
1811
+ ```typescript
1812
+ import { useEffect, useState } from "react";
1813
+ import { useColorScheme as useRNColorScheme } from "react-native";
1814
+
1815
+ /**
1816
+ * To support static rendering, this value needs to be re-calculated on the client side for web
1817
+ */
1818
+ export function useColorScheme() {
1819
+ const [hasHydrated, setHasHydrated] = useState(false);
1820
+
1821
+ useEffect(() => {
1822
+ setHasHydrated(true);
1823
+ }, []);
1824
+
1825
+ const colorScheme = useRNColorScheme();
1826
+
1827
+ if (hasHydrated) {
1828
+ return colorScheme;
1829
+ }
1830
+
1831
+ return "light";
1832
+ }
1833
+
1834
+ ```
1835
+
1836
+ ## hooks/use-theme-color.ts
1837
+
1838
+ ```typescript
1839
+ /**
1840
+ * Learn more about light and dark modes:
1841
+ * https://docs.expo.dev/guides/color-schemes/
1842
+ */
1843
+
1844
+ import { Colors } from "@/constants/theme";
1845
+ import { useColorScheme } from "@/hooks/use-color-scheme";
1846
+
1847
+ export function useThemeColor(
1848
+ props: { light?: string; dark?: string },
1849
+ colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
1850
+ ) {
1851
+ const theme = useColorScheme() ?? "light";
1852
+ const colorFromProps = props[theme];
1853
+
1854
+ if (colorFromProps) {
1855
+ return colorFromProps;
1856
+ } else {
1857
+ return Colors[theme][colorName];
1858
+ }
1859
+ }
1860
+
1861
+ ```
1862
+
1863
+ ## metro.config.js
1864
+
1865
+ ```javascript
1866
+ const { getDefaultConfig } = require("expo/metro-config");
1867
+ const path = require("node:path");
1868
+
1869
+ const projectRoot = __dirname;
1870
+ const monorepoRoot = path.resolve(projectRoot, "../..");
1871
+
1872
+ const config = getDefaultConfig(projectRoot);
1873
+
1874
+ // Watch all files within the monorepo
1875
+ config.watchFolders = [monorepoRoot];
1876
+
1877
+ // Enable symlinks support for pnpm
1878
+ config.resolver.unstable_enableSymlinks = true;
1879
+
1880
+ // Let Metro know where to resolve packages
1881
+ config.resolver.nodeModulesPaths = [
1882
+ path.resolve(projectRoot, "node_modules"),
1883
+ path.resolve(monorepoRoot, "node_modules"),
1884
+ ];
1885
+
1886
+ // Force resolving shared dependencies from the app's node_modules
1887
+ config.resolver.resolveRequest = (context, moduleName, platform) => {
1888
+ if (
1889
+ moduleName === "react" ||
1890
+ moduleName === "react-native" ||
1891
+ moduleName.startsWith("react/") ||
1892
+ moduleName.startsWith("react-native/")
1893
+ ) {
1894
+ return context.resolveRequest(
1895
+ {
1896
+ ...context,
1897
+ originModulePath: path.resolve(projectRoot, "package.json"),
1898
+ },
1899
+ moduleName,
1900
+ platform,
1901
+ );
1902
+ }
1903
+
1904
+ return context.resolveRequest(context, moduleName, platform);
1905
+ };
1906
+
1907
+ module.exports = config;
1908
+
1909
+ ```
1910
+
1911
+ ## package.json
1912
+
1913
+ ```json
1914
+ {
1915
+ "name": "with-expo",
1916
+ "main": "expo-router/entry",
1917
+ "version": "0.0.0",
1918
+ "scripts": {
1919
+ "start": "expo start",
1920
+ "android": "expo run:android",
1921
+ "ios": "expo run:ios",
1922
+ "web": "expo start --web",
1923
+ "lint": "expo lint"
1924
+ },
1925
+ "dependencies": {
1926
+ "@assistant-ui/react-native": "workspace:*",
1927
+ "@expo/vector-icons": "^15.1.1",
1928
+ "@react-navigation/drawer": "^7.8.1",
1929
+ "@react-navigation/native": "^7.1.28",
1930
+ "expo": "~54.0.33",
1931
+ "expo-constants": "~18.0.13",
1932
+ "expo-font": "~14.0.11",
1933
+ "expo-image-picker": "~17.0.10",
1934
+ "expo-linking": "~8.0.11",
1935
+ "expo-router": "~6.0.23",
1936
+ "expo-splash-screen": "~31.0.13",
1937
+ "expo-status-bar": "~3.0.9",
1938
+ "expo-system-ui": "~6.0.9",
1939
+ "react": "19.2.4",
1940
+ "react-dom": "19.2.4",
1941
+ "react-native": "0.84.0",
1942
+ "react-native-gesture-handler": "~2.30.0",
1943
+ "react-native-reanimated": "~4.2.2",
1944
+ "react-native-safe-area-context": "~5.7.0",
1945
+ "react-native-screens": "~4.24.0",
1946
+ "react-native-web": "~0.21.2",
1947
+ "react-native-worklets": "0.7.4"
1948
+ },
1949
+ "devDependencies": {
1950
+ "@types/react": "~19.2.14",
1951
+ "eslint": "^10.0.2",
1952
+ "eslint-config-expo": "~10.0.0",
1953
+ "typescript": "~5.9.3"
1954
+ },
1955
+ "private": true
1956
+ }
1957
+
1958
+ ```
1959
+
1960
+ ## README.md
1961
+
1962
+ ```markdown
1963
+ # Welcome to your Expo app 👋
1964
+
1965
+ This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
1966
+
1967
+ ## Get started
1968
+
1969
+ 1. Install dependencies
1970
+
1971
+ ```bash
1972
+ npm install
1973
+ ```
1974
+
1975
+ 2. Start the app
1976
+
1977
+ ```bash
1978
+ npx expo start
1979
+ ```
1980
+
1981
+ In the output, you'll find options to open the app in a
1982
+
1983
+ - [development build](https://docs.expo.dev/develop/development-builds/introduction/)
1984
+ - [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
1985
+ - [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
1986
+ - [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
1987
+
1988
+ You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
1989
+
1990
+ ## Get a fresh project
1991
+
1992
+ When you're ready, run:
1993
+
1994
+ ```bash
1995
+ npm run reset-project
1996
+ ```
1997
+
1998
+ This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
1999
+
2000
+ ## Learn more
2001
+
2002
+ To learn more about developing your project with Expo, look at the following resources:
2003
+
2004
+ - [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
2005
+ - [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
2006
+
2007
+ ## Join the community
2008
+
2009
+ Join our community of developers creating universal apps.
2010
+
2011
+ - [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
2012
+ - [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
2013
+
2014
+ ```
2015
+
2016
+ ## tsconfig.json
2017
+
2018
+ ```json
2019
+ {
2020
+ "extends": "expo/tsconfig.base",
2021
+ "compilerOptions": {
2022
+ "strict": true,
2023
+ "paths": {
2024
+ "@/*": ["./*"]
2025
+ }
2026
+ },
2027
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
2028
+ }
2029
+
2030
+ ```
2031
+