@alia.onl/sdk 2.0.0 → 3.1.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 (37) hide show
  1. package/package.json +25 -3
  2. package/src/components/AliaChatContent.tsx +356 -0
  3. package/src/components/AliaChatInput.tsx +173 -26
  4. package/src/components/AliaChatMessageList.tsx +459 -168
  5. package/src/components/AliaChatScreen.tsx +140 -0
  6. package/src/components/AliaChatSheet.tsx +41 -44
  7. package/src/components/AliaWelcomeMessage.tsx +75 -0
  8. package/src/components/Markdown.tsx +58 -114
  9. package/src/components/PlanPreviewCard.tsx +76 -0
  10. package/src/components/ResearchProgressCard.tsx +159 -0
  11. package/src/components/ui/button.tsx +104 -0
  12. package/src/components/ui/chat-text-input.tsx +164 -0
  13. package/src/components/ui/dropdown-menu.tsx +2 -0
  14. package/src/components/ui/dropdown-menu.web.tsx +355 -0
  15. package/src/components/ui/prompt-input/actions.tsx +28 -0
  16. package/src/components/ui/prompt-input/add-menu.tsx +83 -0
  17. package/src/components/ui/prompt-input/attachments.tsx +183 -0
  18. package/src/components/ui/prompt-input/autocomplete.tsx +168 -0
  19. package/src/components/ui/prompt-input/context.tsx +71 -0
  20. package/src/components/ui/prompt-input/mic-button.tsx +53 -0
  21. package/src/components/ui/prompt-input/prompt-input.tsx +276 -0
  22. package/src/components/ui/prompt-input/submit-button.tsx +49 -0
  23. package/src/components/ui/prompt-input/textarea.tsx +64 -0
  24. package/src/components/ui/text.tsx +29 -0
  25. package/src/hooks/useAliaChat.ts +18 -8
  26. package/src/hooks/useDocumentPicker.ts +37 -0
  27. package/src/hooks/useImagePicker.ts +38 -0
  28. package/src/hooks/useSpeechToText.ts +25 -9
  29. package/src/index.ts +41 -3
  30. package/src/lib/content-utils.ts +23 -0
  31. package/src/lib/keyboard.native.tsx +6 -0
  32. package/src/lib/keyboard.tsx +25 -0
  33. package/src/lib/tool-registry.ts +55 -0
  34. package/src/lib/utils.ts +14 -0
  35. package/src/optional-deps.d.ts +17 -0
  36. package/src/types.ts +27 -10
  37. package/src/components/AliaChatSuggestions.tsx +0 -70
@@ -0,0 +1,355 @@
1
+ // Web dropdown menu using Radix UI with Tailwind styling.
2
+ // Provides the same zeego-compatible API as the native dropdown-menu.tsx.
3
+ import { cn } from "../../lib/utils";
4
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5
+ import { CheckIcon, ChevronRightIcon } from "@radix-ui/react-icons";
6
+ import * as React from "react";
7
+ import {
8
+ Star, Pencil, Trash2, Share2, Download, Settings, HelpCircle,
9
+ Image, FileText, Search, ShoppingBag, MoreHorizontal, ExternalLink,
10
+ BookOpen, Globe, PenTool, Sparkles, User, CreditCard, Bell, LogOut,
11
+ Folder, Check, Brain, Ghost, Bot, Bookmark, AlertTriangle, Pin,
12
+ } from "lucide-react-native";
13
+
14
+ // Map iOS SF Symbol names to Lucide icons for web rendering
15
+ const SF_SYMBOL_MAP: Record<string, React.ComponentType<any>> = {
16
+ "star": Star, "star.fill": Star, "pencil": Pencil, "trash": Trash2,
17
+ "square.and.arrow.up": Share2, "arrow.down.doc": Download,
18
+ "gearshape": Settings, "questionmark.circle": HelpCircle,
19
+ "photo": Image, "doc": FileText, "magnifyingglass": Search,
20
+ "bag": ShoppingBag, "ellipsis": MoreHorizontal, "link": ExternalLink,
21
+ "book": BookOpen, "globe": Globe, "pencil.tip": PenTool,
22
+ "sparkle": Sparkles, "person.circle": User, "creditcard": CreditCard,
23
+ "bell": Bell, "rectangle.portrait.and.arrow.right": LogOut,
24
+ "folder": Folder, "checkmark": Check, "brain": Brain,
25
+ "eye.slash": Ghost, "cpu": Bot,
26
+ "bookmark": Bookmark, "bookmark.fill": Bookmark,
27
+ "exclamationmark.triangle": AlertTriangle,
28
+ "pin": Pin, "pin.fill": Pin,
29
+ };
30
+
31
+
32
+ // Singleton registry: only one dropdown menu open at a time.
33
+ const openMenus = new Set<() => void>();
34
+
35
+ function DropdownMenu({
36
+ open: controlledOpen,
37
+ onOpenChange,
38
+ defaultOpen,
39
+ ...props
40
+ }: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Root>) {
41
+ const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false);
42
+ const open = controlledOpen ?? internalOpen;
43
+
44
+ const onOpenChangeRef = React.useRef(onOpenChange);
45
+ onOpenChangeRef.current = onOpenChange;
46
+
47
+ const closeRef = React.useRef<() => void>(undefined);
48
+ if (!closeRef.current) {
49
+ closeRef.current = () => {
50
+ setInternalOpen(false);
51
+ onOpenChangeRef.current?.(false);
52
+ };
53
+ }
54
+
55
+ React.useEffect(() => {
56
+ const cb = closeRef.current!;
57
+ openMenus.add(cb);
58
+ return () => { openMenus.delete(cb); };
59
+ }, []);
60
+
61
+ const handleOpenChange = (next: boolean) => {
62
+ if (next) {
63
+ openMenus.forEach((cb) => {
64
+ if (cb !== closeRef.current) cb();
65
+ });
66
+ }
67
+ setInternalOpen(next);
68
+ onOpenChange?.(next);
69
+ };
70
+
71
+ return (
72
+ <DropdownMenuPrimitive.Root
73
+ modal={false}
74
+ open={open}
75
+ onOpenChange={handleOpenChange}
76
+ {...props}
77
+ />
78
+ );
79
+ }
80
+
81
+ const DropdownMenuTrigger = React.forwardRef<
82
+ React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
83
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
84
+ >((props, ref) => (
85
+ <DropdownMenuPrimitive.Trigger ref={ref} asChild {...props} />
86
+ ));
87
+ DropdownMenuTrigger.displayName = "DropdownMenuTrigger";
88
+
89
+ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
90
+
91
+ const DropdownMenuGroup = React.forwardRef<
92
+ React.ElementRef<typeof DropdownMenuPrimitive.Group>,
93
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group> & {
94
+ horizontal?: boolean;
95
+ }
96
+ >(({ className, horizontal, children, ...props }, ref) => (
97
+ <DropdownMenuPrimitive.Group
98
+ ref={ref}
99
+ className={cn(
100
+ horizontal && "flex flex-row space-x-2 justify-between",
101
+ className
102
+ )}
103
+ {...props}
104
+ >
105
+ {children}
106
+ </DropdownMenuPrimitive.Group>
107
+ ));
108
+ DropdownMenuGroup.displayName = DropdownMenuPrimitive.Group.displayName;
109
+
110
+ const DropdownMenuSubTrigger = React.forwardRef<
111
+ React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
112
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
113
+ inset?: boolean;
114
+ }
115
+ >(({ className, inset, children, ...props }, ref) => (
116
+ <DropdownMenuPrimitive.SubTrigger
117
+ ref={ref}
118
+ className={cn(
119
+ "flex cursor-default select-none items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground [&:focus_svg:not([class*='text-'])]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
120
+ inset && "pl-8",
121
+ className
122
+ )}
123
+ {...props}
124
+ >
125
+ {children}
126
+ <ChevronRightIcon className="ml-auto h-4 w-4" />
127
+ </DropdownMenuPrimitive.SubTrigger>
128
+ ));
129
+ DropdownMenuSubTrigger.displayName =
130
+ DropdownMenuPrimitive.SubTrigger.displayName;
131
+
132
+ const DropdownMenuSubContent = React.forwardRef<
133
+ React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
134
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
135
+ >(({ className, style, ...props }, ref) => (
136
+ <DropdownMenuPrimitive.Portal>
137
+ <DropdownMenuPrimitive.SubContent
138
+ ref={ref}
139
+ className={cn(
140
+ "z-[9999] min-w-[96px] rounded-md bg-popover p-1 text-popover-foreground duration-100 origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
141
+ className
142
+ )}
143
+ style={{
144
+ boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)',
145
+ border: '1px solid hsl(var(--border))',
146
+ ...style as any,
147
+ }}
148
+ {...props}
149
+ />
150
+ </DropdownMenuPrimitive.Portal>
151
+ ));
152
+ DropdownMenuSubContent.displayName =
153
+ DropdownMenuPrimitive.SubContent.displayName;
154
+
155
+ const DropdownMenuContent = React.forwardRef<
156
+ React.ElementRef<typeof DropdownMenuPrimitive.Content>,
157
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
158
+ >(({ className, sideOffset = 4, align = "start", style, ...props }, ref) => (
159
+ <DropdownMenuPrimitive.Portal>
160
+ <DropdownMenuPrimitive.Content
161
+ ref={ref}
162
+ sideOffset={sideOffset}
163
+ align={align}
164
+ className={cn(
165
+ "z-[9999] min-w-[8rem] rounded-lg bg-popover p-1 text-popover-foreground duration-100 max-h-[var(--radix-dropdown-menu-content-available-height)] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden",
166
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
167
+ className
168
+ )}
169
+ style={{
170
+ boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)',
171
+ border: '1px solid hsl(var(--border))',
172
+ ...style as any,
173
+ }}
174
+ {...props}
175
+ />
176
+ </DropdownMenuPrimitive.Portal>
177
+ ));
178
+ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
179
+
180
+ const DropdownMenuItem = React.forwardRef<
181
+ React.ElementRef<typeof DropdownMenuPrimitive.Item>,
182
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
183
+ inset?: boolean;
184
+ destructive?: boolean;
185
+ shouldDismissMenuOnSelect?: boolean;
186
+ }
187
+ >(
188
+ (
189
+ {
190
+ className,
191
+ inset,
192
+ destructive,
193
+ shouldDismissMenuOnSelect,
194
+ onSelect,
195
+ ...props
196
+ },
197
+ ref
198
+ ) => (
199
+ <DropdownMenuPrimitive.Item
200
+ ref={ref}
201
+ onSelect={(e: any) => {
202
+ onSelect?.(e);
203
+ if (shouldDismissMenuOnSelect === false) {
204
+ e.preventDefault();
205
+ }
206
+ }}
207
+ className={cn(
208
+ "relative flex flex-row cursor-default select-none items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='text-'])]:text-muted-foreground [&:focus_svg:not([class*='text-'])]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
209
+ inset && "pl-8",
210
+ destructive &&
211
+ "text-destructive focus:text-destructive focus:bg-destructive/10 dark:focus:bg-destructive/20 [&_svg]:!text-destructive",
212
+ className
213
+ )}
214
+ {...props}
215
+ />
216
+ )
217
+ );
218
+ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
219
+
220
+ const DropdownMenuCheckboxItem = React.forwardRef<
221
+ React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
222
+ Omit<
223
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>,
224
+ "onSelect" | "checked"
225
+ > & {
226
+ value: "mixed" | "on" | "off" | boolean;
227
+ onValueChange?: (
228
+ state: "mixed" | "on" | "off",
229
+ prevState: "mixed" | "on" | "off"
230
+ ) => void;
231
+ shouldDismissMenuOnSelect?: boolean;
232
+ }
233
+ >(
234
+ (
235
+ {
236
+ className,
237
+ value,
238
+ children,
239
+ shouldDismissMenuOnSelect,
240
+ onValueChange,
241
+ ...props
242
+ },
243
+ ref
244
+ ) => (
245
+ <DropdownMenuPrimitive.CheckboxItem
246
+ ref={ref}
247
+ onSelect={(e: any) => {
248
+ const current =
249
+ value === true ? "on" : value === false ? "off" : value;
250
+ const next = current === "on" ? "off" : "on";
251
+ onValueChange?.(next, current);
252
+ if (shouldDismissMenuOnSelect === false) {
253
+ e.preventDefault();
254
+ }
255
+ }}
256
+ className={cn(
257
+ "relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground [&:focus_svg:not([class*='text-'])]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
258
+ className
259
+ )}
260
+ checked={typeof value === "boolean" ? value : value !== "off"}
261
+ {...props}
262
+ >
263
+ <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
264
+ <DropdownMenuPrimitive.ItemIndicator>
265
+ <CheckIcon className="h-4 w-4" />
266
+ </DropdownMenuPrimitive.ItemIndicator>
267
+ </span>
268
+ {children}
269
+ </DropdownMenuPrimitive.CheckboxItem>
270
+ )
271
+ );
272
+ DropdownMenuCheckboxItem.displayName =
273
+ DropdownMenuPrimitive.CheckboxItem.displayName;
274
+
275
+ const DropdownMenuLabel = React.forwardRef<
276
+ React.ElementRef<typeof DropdownMenuPrimitive.Label>,
277
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
278
+ inset?: boolean;
279
+ }
280
+ >(({ className, inset, ...props }, ref) => (
281
+ <DropdownMenuPrimitive.Label
282
+ ref={ref}
283
+ className={cn(
284
+ "text-muted-foreground px-1.5 py-1 text-xs font-medium",
285
+ inset && "pl-8",
286
+ className
287
+ )}
288
+ {...props}
289
+ />
290
+ ));
291
+ DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
292
+
293
+ const DropdownMenuSeparator = React.forwardRef<
294
+ React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
295
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
296
+ >(({ className, ...props }, ref) => (
297
+ <DropdownMenuPrimitive.Separator
298
+ ref={ref}
299
+ className={cn("-mx-1 my-1 h-px bg-border", className)}
300
+ {...props}
301
+ />
302
+ ));
303
+ DropdownMenuSeparator.displayName =
304
+ DropdownMenuPrimitive.Separator.displayName;
305
+
306
+ export const ItemIcon = React.forwardRef<
307
+ HTMLSpanElement,
308
+ React.HTMLProps<HTMLSpanElement> & { ios?: { name?: string; [key: string]: any }; androidIconName?: string }
309
+ >(({ className, ios, androidIconName, children, ...props }, ref) => {
310
+ const IconComponent = ios?.name ? SF_SYMBOL_MAP[ios.name] : null;
311
+ return (
312
+ <span ref={ref} className={cn("inline-flex h-4 w-4 items-center justify-center shrink-0 text-muted-foreground", className)} {...props}>
313
+ {IconComponent ? <IconComponent size={16} /> : children}
314
+ </span>
315
+ );
316
+ });
317
+ ItemIcon.displayName = "ItemIcon";
318
+
319
+ export const ItemTitle = React.forwardRef<
320
+ HTMLSpanElement,
321
+ React.HTMLProps<HTMLSpanElement>
322
+ >((props, ref) => {
323
+ return <span ref={ref} {...props} />;
324
+ });
325
+ ItemTitle.displayName = "ItemTitle";
326
+
327
+ export const ItemSubtitle = React.forwardRef<
328
+ HTMLSpanElement,
329
+ React.HTMLProps<HTMLSpanElement>
330
+ >(({ className, ...props }, ref) => {
331
+ return (
332
+ <span
333
+ ref={ref}
334
+ className={cn("block text-xs text-muted-foreground mt-0.5", className)}
335
+ {...props}
336
+ />
337
+ );
338
+ });
339
+ ItemSubtitle.displayName = "ItemSubtitle";
340
+
341
+ export { ItemImage, ItemIndicator, Arrow } from "zeego/dropdown-menu";
342
+
343
+ export {
344
+ DropdownMenu as Root,
345
+ DropdownMenuTrigger as Trigger,
346
+ DropdownMenuContent as Content,
347
+ DropdownMenuItem as Item,
348
+ DropdownMenuCheckboxItem as CheckboxItem,
349
+ DropdownMenuLabel as Label,
350
+ DropdownMenuSeparator as Separator,
351
+ DropdownMenuGroup as Group,
352
+ DropdownMenuSub as Sub,
353
+ DropdownMenuSubContent as SubContent,
354
+ DropdownMenuSubTrigger as SubTrigger,
355
+ };
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+ import { View } from "react-native";
3
+ import { cn } from "../../../lib/utils";
4
+ import { usePromptInput } from "./context";
5
+
6
+ export type PromptInputActionsProps = React.ComponentProps<typeof View>;
7
+
8
+ export function PromptInputActions({
9
+ children,
10
+ className,
11
+ ...props
12
+ }: PromptInputActionsProps) {
13
+ const { isFullscreen } = usePromptInput();
14
+
15
+ return (
16
+ <View
17
+ className={cn(
18
+ "flex-row items-center gap-2",
19
+ isFullscreen &&
20
+ "absolute bottom-4 left-4 right-4 max-w-2xl mx-auto rounded-full border border-border bg-background px-4 py-3",
21
+ className
22
+ )}
23
+ {...props}
24
+ >
25
+ {children}
26
+ </View>
27
+ );
28
+ }
@@ -0,0 +1,83 @@
1
+ import React from "react";
2
+ import { Plus } from "lucide-react-native";
3
+ import { cn } from "../../../lib/utils";
4
+ import { Button } from "../button";
5
+ import * as DropdownMenu from "../dropdown-menu";
6
+ import { useImagePicker } from "../../../hooks/useImagePicker";
7
+ import { useDocumentPicker } from "../../../hooks/useDocumentPicker";
8
+ import { usePromptInput } from "./context";
9
+
10
+ export type PromptInputAddMenuProps = {
11
+ className?: string;
12
+ iconSize?: number;
13
+ };
14
+
15
+ export function PromptInputAddMenu({ className, iconSize = 16 }: PromptInputAddMenuProps) {
16
+ const { addAttachment } = usePromptInput();
17
+ const { pickImage } = useImagePicker();
18
+ const { pickDocument } = useDocumentPicker();
19
+
20
+ const handleAddPhotos = async () => {
21
+ try {
22
+ const assets = await pickImage();
23
+ if (assets && assets.length > 0) {
24
+ assets.forEach((asset) => {
25
+ addAttachment({
26
+ id: `img-${Date.now()}-${Math.random().toString(36).slice(2)}`,
27
+ uri: asset.uri,
28
+ type: "image",
29
+ name: asset.name,
30
+ size: asset.size,
31
+ mimeType: asset.mimeType,
32
+ });
33
+ });
34
+ }
35
+ } catch (err) {
36
+ console.error("Error picking images:", err);
37
+ }
38
+ };
39
+
40
+ const handleAddDocument = async () => {
41
+ try {
42
+ const docs = await pickDocument();
43
+ if (docs && docs.length > 0) {
44
+ docs.forEach((doc) => {
45
+ addAttachment({
46
+ id: `doc-${Date.now()}-${Math.random().toString(36).slice(2)}`,
47
+ uri: doc.uri,
48
+ type: "document",
49
+ name: doc.name,
50
+ size: doc.size,
51
+ mimeType: doc.mimeType,
52
+ });
53
+ });
54
+ }
55
+ } catch (err) {
56
+ console.error("Error picking documents:", err);
57
+ }
58
+ };
59
+
60
+ return (
61
+ <DropdownMenu.Root>
62
+ <DropdownMenu.Trigger>
63
+ <Button
64
+ variant="outline"
65
+ size="icon"
66
+ className={cn("h-8 w-8 rounded-full border-0", className)}
67
+ >
68
+ <Plus size={iconSize} className="text-muted-foreground" />
69
+ </Button>
70
+ </DropdownMenu.Trigger>
71
+ <DropdownMenu.Content side="top" align="start">
72
+ <DropdownMenu.Item key="photos" onSelect={handleAddPhotos}>
73
+ <DropdownMenu.ItemIcon ios={{ name: "photo" }} />
74
+ <DropdownMenu.ItemTitle>Add photos</DropdownMenu.ItemTitle>
75
+ </DropdownMenu.Item>
76
+ <DropdownMenu.Item key="document" onSelect={handleAddDocument}>
77
+ <DropdownMenu.ItemIcon ios={{ name: "doc" }} />
78
+ <DropdownMenu.ItemTitle>Add document</DropdownMenu.ItemTitle>
79
+ </DropdownMenu.Item>
80
+ </DropdownMenu.Content>
81
+ </DropdownMenu.Root>
82
+ );
83
+ }
@@ -0,0 +1,183 @@
1
+ import React from "react";
2
+ import { View, Pressable, ScrollView, ActivityIndicator } from "react-native";
3
+ import { Image } from "expo-image";
4
+ import {
5
+ FileText,
6
+ FileSpreadsheet,
7
+ FileCode,
8
+ FileArchive,
9
+ FileAudio,
10
+ File,
11
+ X,
12
+ } from "lucide-react-native";
13
+ import { Text } from "../text";
14
+ import { formatFileSize } from "../../../lib/utils";
15
+ import { usePromptInput, type Attachment } from "./context";
16
+
17
+ function getDocumentIcon(mimeType: string, name: string) {
18
+ const ext = name.split(".").pop()?.toLowerCase() || "";
19
+
20
+ if (mimeType === "application/pdf" || ext === "pdf")
21
+ return { Icon: FileText, color: "#EF4444", bgColor: "#EF444418" };
22
+ if (mimeType.includes("word") || ["doc", "docx"].includes(ext))
23
+ return { Icon: FileText, color: "#3B82F6", bgColor: "#3B82F618" };
24
+ if (
25
+ mimeType.includes("spreadsheet") ||
26
+ mimeType.includes("excel") ||
27
+ ["xls", "xlsx", "csv"].includes(ext)
28
+ )
29
+ return {
30
+ Icon: FileSpreadsheet,
31
+ color: "#22C55E",
32
+ bgColor: "#22C55E18",
33
+ };
34
+ if (
35
+ [
36
+ "js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java", "c", "cpp",
37
+ "h", "json", "xml", "yaml", "yml", "html", "css", "scss", "sh", "sql",
38
+ ].includes(ext)
39
+ )
40
+ return { Icon: FileCode, color: "#8B5CF6", bgColor: "#8B5CF618" };
41
+ if (
42
+ mimeType.includes("zip") ||
43
+ mimeType.includes("archive") ||
44
+ ["zip", "rar", "tar", "gz", "7z"].includes(ext)
45
+ )
46
+ return { Icon: FileArchive, color: "#EAB308", bgColor: "#EAB30818" };
47
+ if (
48
+ mimeType.startsWith("audio/") ||
49
+ ["mp3", "wav", "ogg", "flac", "aac"].includes(ext)
50
+ )
51
+ return { Icon: FileAudio, color: "#EC4899", bgColor: "#EC489918" };
52
+ if (
53
+ mimeType === "text/plain" ||
54
+ ["txt", "md", "rtf"].includes(ext)
55
+ )
56
+ return { Icon: FileText, color: "#6B7280", bgColor: "#6B728018" };
57
+ return { Icon: File, color: "#9CA3AF", bgColor: "#9CA3AF18" };
58
+ }
59
+
60
+ function truncateFilename(name: string, maxLength = 20): string {
61
+ if (name.length <= maxLength) return name;
62
+ const lastDot = name.lastIndexOf(".");
63
+ if (lastDot < 0) return name.slice(0, maxLength - 3) + "...";
64
+ const ext = name.slice(lastDot);
65
+ const base = name.slice(0, lastDot);
66
+ const available = maxLength - ext.length - 3;
67
+ if (available <= 0) return name.slice(0, maxLength - 3) + "...";
68
+ return base.slice(0, available) + "..." + ext;
69
+ }
70
+
71
+ function AttachmentItem({
72
+ attachment,
73
+ onRemove,
74
+ }: {
75
+ attachment: Attachment;
76
+ onRemove: () => void;
77
+ }) {
78
+ if (attachment.type === "image") {
79
+ return (
80
+ <View
81
+ className="relative rounded-2xl overflow-hidden bg-muted border border-border"
82
+ style={{ width: 120, height: 120 }}
83
+ >
84
+ {!attachment.isLoading && attachment.uri ? (
85
+ <Image
86
+ source={{ uri: attachment.uri }}
87
+ className="w-full h-full"
88
+ contentFit="cover"
89
+ />
90
+ ) : (
91
+ <View className="absolute inset-0 items-center justify-center bg-muted">
92
+ <ActivityIndicator size="small" />
93
+ </View>
94
+ )}
95
+ {attachment.name && !attachment.isLoading && (
96
+ <View
97
+ className="absolute bottom-0 left-0 right-0 px-2 py-1"
98
+ style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
99
+ >
100
+ <Text className="text-[11px] text-white" numberOfLines={1}>
101
+ {truncateFilename(attachment.name)}
102
+ </Text>
103
+ </View>
104
+ )}
105
+ <Pressable
106
+ onPress={onRemove}
107
+ className="absolute top-1.5 right-1.5 w-6 h-6 rounded-full items-center justify-center active:opacity-70"
108
+ style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
109
+ accessibilityRole="button"
110
+ accessibilityLabel={`Remove ${attachment.name || "image"}`}
111
+ >
112
+ <X size={14} color="white" />
113
+ </Pressable>
114
+ </View>
115
+ );
116
+ }
117
+
118
+ const { Icon, color, bgColor } = getDocumentIcon(
119
+ attachment.mimeType,
120
+ attachment.name
121
+ );
122
+
123
+ return (
124
+ <View
125
+ className="relative rounded-2xl border border-border overflow-hidden bg-muted/30"
126
+ style={{ width: 180, height: 80 }}
127
+ >
128
+ <View className="flex-1 flex-row items-center px-3 gap-3">
129
+ <View
130
+ className="w-10 h-10 rounded-xl items-center justify-center"
131
+ style={{ backgroundColor: bgColor }}
132
+ >
133
+ <Icon size={20} color={color} />
134
+ </View>
135
+ <View className="flex-1 pr-4">
136
+ <Text
137
+ className="text-xs font-medium text-foreground"
138
+ numberOfLines={1}
139
+ >
140
+ {truncateFilename(attachment.name)}
141
+ </Text>
142
+ {attachment.size > 0 && (
143
+ <Text className="text-[11px] text-muted-foreground mt-0.5">
144
+ {formatFileSize(attachment.size)}
145
+ </Text>
146
+ )}
147
+ </View>
148
+ </View>
149
+ <Pressable
150
+ onPress={onRemove}
151
+ className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-muted items-center justify-center active:opacity-70 border border-border"
152
+ accessibilityRole="button"
153
+ accessibilityLabel={`Remove ${attachment.name || "document"}`}
154
+ >
155
+ <X size={12} className="text-foreground" />
156
+ </Pressable>
157
+ </View>
158
+ );
159
+ }
160
+
161
+ export function PromptInputAttachments() {
162
+ const { attachments, removeAttachment } = usePromptInput();
163
+
164
+ if (attachments.length === 0) return null;
165
+
166
+ return (
167
+ <ScrollView
168
+ horizontal
169
+ showsHorizontalScrollIndicator={false}
170
+ className="mb-2 pt-2 flex-none"
171
+ contentContainerClassName="gap-2.5 px-3"
172
+ >
173
+ {attachments.map((attachment) => (
174
+ <View key={attachment.id} className="relative">
175
+ <AttachmentItem
176
+ attachment={attachment}
177
+ onRemove={() => removeAttachment(attachment.id)}
178
+ />
179
+ </View>
180
+ ))}
181
+ </ScrollView>
182
+ );
183
+ }