diff --git a/apps/mobile/__tests__/components/chat/chat-input-bar.test.tsx b/apps/mobile/__tests__/components/chat/chat-input-bar.test.tsx index 6f9496dec..fb630fc3c 100644 --- a/apps/mobile/__tests__/components/chat/chat-input-bar.test.tsx +++ b/apps/mobile/__tests__/components/chat/chat-input-bar.test.tsx @@ -14,6 +14,7 @@ vi.mock('lucide-react-native', () => { Image: icon('Image'), Lock: icon('Lock'), Mic: icon('Mic'), + Paperclip: icon('Paperclip'), Square: icon('Square'), ArrowUp: icon('ArrowUp'), } @@ -42,6 +43,7 @@ function buildProps(overrides: Record = {}) { atMessageLimit: false, limitLocked: false, selectedImagePresent: false, + selectedTextFilePresent: false, transcript: '', composerResetSignal: 0, recordingTime: '0:00', @@ -49,6 +51,7 @@ function buildProps(overrides: Record = {}) { onSend: vi.fn(), onToggleRecording: vi.fn(), onOpenFilePicker: vi.fn(), + onOpenTextFilePicker: vi.fn(), ...overrides, } } @@ -87,3 +90,57 @@ describe('ChatInputBar voice transcript (mobile)', () => { expect(findInputValue(tree!.root)).toBe('buy milk') }) }) + +describe('ChatInputBar text-file attachment (mobile)', () => { + it('invokes the text-file picker from the attach-file control', async () => { + const onOpenTextFilePicker = vi.fn() + let tree: ReturnType + + await TestRenderer.act(async () => { + tree = TestRenderer.create( + , + ) + await Promise.resolve() + }) + + const attachButton = tree!.root.findAll( + (node: { props?: Record }) => + !!node.props && + node.props.accessibilityLabel === 'chat.attachFile' && + typeof node.props.onPress === 'function', + )[0] as { props: { onPress: () => void } } | undefined + + TestRenderer.act(() => { + attachButton?.props.onPress() + }) + + expect(onOpenTextFilePicker).toHaveBeenCalled() + }) + + it('allows sending when only a text file is attached', async () => { + const onSend = vi.fn() + let tree: ReturnType + + await TestRenderer.act(async () => { + tree = TestRenderer.create( + , + ) + await Promise.resolve() + }) + + const sendButton = tree!.root.findAll( + (node: { props?: Record }) => + !!node.props && + node.props.accessibilityLabel === 'chat.send' && + typeof node.props.onPress === 'function', + )[0] as { props: { onPress: () => void } } | undefined + + TestRenderer.act(() => { + sendButton?.props.onPress() + }) + + expect(onSend).toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/__tests__/hooks/use-chat-composer.test.tsx b/apps/mobile/__tests__/hooks/use-chat-composer.test.tsx index 676069519..bf5943787 100644 --- a/apps/mobile/__tests__/hooks/use-chat-composer.test.tsx +++ b/apps/mobile/__tests__/hooks/use-chat-composer.test.tsx @@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => { queryClient, apiClient: vi.fn(), openChatStream: vi.fn(), + getDocumentAsync: vi.fn(), routerPush: vi.fn(), useQueryClient: vi.fn(() => queryClient), } @@ -51,6 +52,10 @@ vi.mock('expo-image-picker', () => ({ launchImageLibraryAsync: vi.fn(), })) +vi.mock('expo-document-picker', () => ({ + getDocumentAsync: mocks.getDocumentAsync, +})) + vi.mock('@/hooks/use-profile', () => ({ useProfile: () => ({ profile: mocks.state.profile }), })) @@ -142,6 +147,7 @@ describe('mobile useChatComposer', () => { mocks.state.profile = undefined mocks.apiClient.mockReset() mocks.openChatStream.mockReset() + mocks.getDocumentAsync.mockReset() mocks.routerPush.mockReset() mocks.queryClient.invalidateQueries.mockClear() mocks.queryClient.setQueryData.mockClear() @@ -262,6 +268,52 @@ describe('mobile useChatComposer', () => { expect(composer.current.sendError).toBe('You are offline') }) + it('reads an attached text file and folds it into the sent message', async () => { + mocks.getDocumentAsync.mockResolvedValue({ + canceled: false, + assets: [ + { name: 'habits.csv', uri: 'file:///tmp/habits.csv', size: 12, mimeType: 'text/csv' }, + ], + }) + mocks.openChatStream.mockResolvedValue( + sseStreamResponse(finalFrame(makeChatResponse({ aiMessage: 'Imported' }))), + ) + const composer = await renderComposer() + + await TestRenderer.act(async () => { + await composer.current.openTextFilePicker() + }) + expect(composer.current.selectedTextFile?.name).toBe('habits.csv') + + await TestRenderer.act(async () => { + await composer.current.sendMessage() + }) + + const userMessage = useChatStore + .getState() + .messages.find((message) => message.role === 'user') + expect(userMessage?.content).toContain('mock-file-content') + expect(userMessage?.content).toContain('chat.fileAttached') + expect(composer.current.selectedTextFile).toBeNull() + }) + + it('surfaces the i18n error for an unsupported attachment type', async () => { + mocks.getDocumentAsync.mockResolvedValue({ + canceled: false, + assets: [ + { name: 'photo.png', uri: 'file:///tmp/photo.png', size: 12, mimeType: 'image/png' }, + ], + }) + const composer = await renderComposer() + + await TestRenderer.act(async () => { + await composer.current.openTextFilePicker() + }) + + expect(composer.current.sendError).toBe('chat.fileError') + expect(composer.current.selectedTextFile).toBeNull() + }) + it('aborts an idle stream at the watchdog and arms retry with the timeout copy', async () => { vi.useFakeTimers() try { diff --git a/apps/mobile/app/chat.styles.ts b/apps/mobile/app/chat.styles.ts index 64498e17e..bc2849462 100644 --- a/apps/mobile/app/chat.styles.ts +++ b/apps/mobile/app/chat.styles.ts @@ -117,6 +117,38 @@ export function createStyles(tokens: Tokens) { alignItems: "center", justifyContent: "center", }, + textFileChipRow: { + paddingBottom: 8, + }, + textFileChip: { + flexDirection: "row", + alignItems: "center", + alignSelf: "flex-start", + maxWidth: "100%", + gap: 8, + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 12, + backgroundColor: tokens.bgElev, + borderWidth: StyleSheet.hairlineWidth, + borderColor: tokens.hairline, + }, + textFileChipText: { + flexShrink: 1, + fontFamily: 'Rubik_400Regular', + fontSize: 13, + color: tokens.fg2, + }, + textFileChipRemove: { + width: 20, + height: 20, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + borderColor: tokens.hairlineStrong, + backgroundColor: tokens.bgElev, + alignItems: "center", + justifyContent: "center", + }, quickChipsScroll: { marginBottom: 12, }, diff --git a/apps/mobile/app/chat.tsx b/apps/mobile/app/chat.tsx index 621fa6c5c..8d8f19541 100644 --- a/apps/mobile/app/chat.tsx +++ b/apps/mobile/app/chat.tsx @@ -80,6 +80,9 @@ export default function ChatScreen() { showSuggestions, openFilePicker, removeImage, + selectedTextFile, + openTextFilePicker, + removeTextFile, sendMessage, scrollToBottom, handleBreakdownConfirmed, @@ -246,6 +249,8 @@ export default function ChatScreen() { isTranscribing={isTranscribing} isTyping={isTyping} selectedImagePresent={selectedImage !== null} + selectedTextFileName={selectedTextFile?.name ?? null} + selectedTextFilePresent={selectedTextFile !== null} transcript={transcript} composerResetSignal={composerResetSignal} recordingTime={recordingTime} @@ -262,6 +267,7 @@ export default function ChatScreen() { }, }} onRemoveImage={removeImage} + onRemoveTextFile={removeTextFile} onRetry={() => { void retryLastSend(); }} @@ -275,6 +281,9 @@ export default function ChatScreen() { onOpenFilePicker={() => { void openFilePicker(); }} + onOpenTextFilePicker={() => { + void openTextFilePicker(); + }} onUpgrade={() => router.push("/upgrade")} /> diff --git a/apps/mobile/components/chat/chat-input-area.tsx b/apps/mobile/components/chat/chat-input-area.tsx index eaf98e825..975ee734b 100644 --- a/apps/mobile/components/chat/chat-input-area.tsx +++ b/apps/mobile/components/chat/chat-input-area.tsx @@ -1,6 +1,6 @@ import { forwardRef } from "react"; import { View, Text, TouchableOpacity, Pressable, ScrollView, Image, Linking } from "react-native"; -import { Crown, X, WifiOff } from "lucide-react-native"; +import { Crown, FileText, X, WifiOff } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import Animated, { FadeInLeft, ReduceMotion } from "react-native-reanimated"; import { InfoCard } from "@/components/ui/info-card"; @@ -43,16 +43,20 @@ interface ChatInputAreaProps { isTranscribing: boolean; isTyping: boolean; selectedImagePresent: boolean; + selectedTextFileName: string | null; + selectedTextFilePresent: boolean; transcript: string; composerResetSignal: number; recordingTime: string; speechSupported: boolean; onRemoveImage: () => void; + onRemoveTextFile: () => void; onRetry: () => void; onSendChip: (chip: string) => void; onSend: (message: string) => void; onToggleRecording: () => void; onOpenFilePicker: () => void; + onOpenTextFilePicker: () => void; onUpgrade: () => void; } @@ -67,8 +71,10 @@ interface ChatInputNoticesProps { canRetry: boolean; speechError: string | null; imagePreview: string | null; + selectedTextFileName: string | null; onRetry: () => void; onRemoveImage: () => void; + onRemoveTextFile: () => void; } function ChatInputNotices({ @@ -82,8 +88,10 @@ function ChatInputNotices({ canRetry, speechError, imagePreview, + selectedTextFileName, onRetry, onRemoveImage, + onRemoveTextFile, }: Readonly) { const { t } = useTranslation(); return ( @@ -157,6 +165,26 @@ function ChatInputNotices({ )} + {selectedTextFileName && ( + + + + + {selectedTextFileName} + + + + + + + )} + {hasMessages && !isOnline ? ( @@ -272,6 +300,7 @@ export const ChatInputArea = forwardRef>( canRetry, speechError, imagePreview, + selectedTextFileName, starterChips, hasProAccess, aiMessagesUsed, @@ -280,6 +309,7 @@ export const ChatInputArea = forwardRef>( reward, voiceRef, onRemoveImage, + onRemoveTextFile, onRetry, onSendChip, onUpgrade, @@ -309,8 +339,10 @@ export const ChatInputArea = forwardRef>( canRetry={canRetry} speechError={speechError} imagePreview={imagePreview} + selectedTextFileName={selectedTextFileName} onRetry={onRetry} onRemoveImage={onRemoveImage} + onRemoveTextFile={onRemoveTextFile} /> {hasMessages && ( @@ -332,6 +364,7 @@ export const ChatInputArea = forwardRef>( atMessageLimit={atMessageLimit} limitLocked={!hasProAccess && atMessageLimit} selectedImagePresent={props.selectedImagePresent} + selectedTextFilePresent={props.selectedTextFilePresent} transcript={props.transcript} composerResetSignal={props.composerResetSignal} recordingTime={props.recordingTime} @@ -339,6 +372,7 @@ export const ChatInputArea = forwardRef>( onSend={props.onSend} onToggleRecording={props.onToggleRecording} onOpenFilePicker={props.onOpenFilePicker} + onOpenTextFilePicker={props.onOpenTextFilePicker} /> {!hasProAccess && atMessageLimit && ( diff --git a/apps/mobile/components/chat/chat-input-bar.tsx b/apps/mobile/components/chat/chat-input-bar.tsx index 6afa57c6f..46bd4823f 100644 --- a/apps/mobile/components/chat/chat-input-bar.tsx +++ b/apps/mobile/components/chat/chat-input-bar.tsx @@ -4,6 +4,7 @@ import { Image as ImageIcon, Lock, Mic, + Paperclip, Square, } from "lucide-react-native"; import { useTranslation } from "react-i18next"; @@ -23,6 +24,7 @@ interface ChatInputBarProps { atMessageLimit: boolean; limitLocked: boolean; selectedImagePresent: boolean; + selectedTextFilePresent: boolean; transcript: string; composerResetSignal: number; recordingTime: string; @@ -30,6 +32,7 @@ interface ChatInputBarProps { onSend: (message: string) => void; onToggleRecording: () => void; onOpenFilePicker: () => void; + onOpenTextFilePicker: () => void; } /** @@ -51,6 +54,7 @@ export const ChatInputBar = forwardRef>( atMessageLimit, limitLocked, selectedImagePresent, + selectedTextFilePresent, transcript, composerResetSignal, recordingTime, @@ -58,6 +62,7 @@ export const ChatInputBar = forwardRef>( onSend, onToggleRecording, onOpenFilePicker, + onOpenTextFilePicker, }, voiceRef, ) { @@ -114,7 +119,7 @@ export const ChatInputBar = forwardRef>( }, [draft]); const canSend = - (draft.trim().length > 0 || selectedImagePresent) && + (draft.trim().length > 0 || selectedImagePresent || selectedTextFilePresent) && !isTyping && !atMessageLimit && !isRecording; @@ -186,6 +191,17 @@ export const ChatInputBar = forwardRef>( ) : ( <> + + + + void onAdvancePastHabits: () => void onFinish: () => void + onImport: () => void } function OnboardingStepContent({ @@ -108,8 +111,9 @@ function OnboardingStepContent({ onPackCreateOwn, onAdvancePastHabits, onFinish, + onImport, }: Readonly) { - if (viewingAstra) return + if (viewingAstra) return switch (sharedStep) { case 0: return @@ -354,6 +358,28 @@ export function OnboardingFlow() { router.replace('/') } + async function handleImport() { + await AsyncStorage.setItem( + CHAT_DRAFT_STORAGE_KEY, + t('onboarding.flow.meetAstra.importPrompt'), + ) + try { + await performQueuedApiMutation({ + type: 'completeOnboarding', + scope: 'profile', + endpoint: API.profile.onboarding, + method: 'PUT', + payload: undefined, + dedupeKey: 'profile-onboarding-complete', + }) + } catch { + } + queryClient.setQueryData(profileKeys.detail(), (old) => + old ? { ...old, hasCompletedOnboarding: true } : old, + ) + router.replace('/chat') + } + function handleSkip() { setViewingAstra(false) setSharedStep(ONBOARDING_COMPLETE_STEP) @@ -420,6 +446,7 @@ export function OnboardingFlow() { onPackCreateOwn={handleCreateOwnInstead} onAdvancePastHabits={advancePastHabitSteps} onFinish={handleFinish} + onImport={handleImport} /> diff --git a/apps/mobile/components/onboarding/onboarding-meet-astra.tsx b/apps/mobile/components/onboarding/onboarding-meet-astra.tsx index c3bfbfaec..1d3b81f60 100644 --- a/apps/mobile/components/onboarding/onboarding-meet-astra.tsx +++ b/apps/mobile/components/onboarding/onboarding-meet-astra.tsx @@ -1,13 +1,20 @@ import { useEffect, useMemo } from 'react' import { Animated, StyleSheet, Text, View } from 'react-native' +import { Upload } from 'lucide-react-native' import { useTranslation } from 'react-i18next' import { createTokensV2, easings, type AppTokensV2 } from '@/lib/theme' import { toAnimatedEasing, usePrefersReducedMotion } from '@/lib/motion' import { useAppTheme } from '@/lib/use-app-theme' +import { PillButton } from '@/components/ui/pill-button' import { AstraAvatar } from '@/components/ui/astra-avatar' -/** ob-2 onboarding step: tinted hero disc + Astra intro in the kit chat-bubble language. */ -export function OnboardingMeetAstra() { +interface OnboardingMeetAstraProps { + onImport?: () => void +} + +/** ob-2 onboarding step: tinted hero disc + Astra intro in the kit chat-bubble language. + * When `onImport` is provided, offers an "import from another app" shortcut into Astra. */ +export function OnboardingMeetAstra({ onImport }: Readonly) { const { t } = useTranslation() const { currentScheme, currentTheme } = useAppTheme() const tokens = useMemo( @@ -88,6 +95,17 @@ export function OnboardingMeetAstra() { + + {onImport && ( + } + onPress={onImport} + > + {t('onboarding.flow.meetAstra.import')} + + )} ) } diff --git a/apps/mobile/hooks/use-chat-composer.ts b/apps/mobile/hooks/use-chat-composer.ts index 7cea5133f..3861d8060 100644 --- a/apps/mobile/hooks/use-chat-composer.ts +++ b/apps/mobile/hooks/use-chat-composer.ts @@ -1,14 +1,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FlatList } from "react-native"; import * as ImagePicker from "expo-image-picker"; +import * as DocumentPicker from "expo-document-picker"; +import { File as FileSystemFile } from "expo-file-system"; import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { + buildChatMessageWithFileContent, CHAT_STARTER_CHIP_KEYS, CHAT_STREAM_IDLE_TIMEOUT_MS, + CHAT_TEXT_FILE_PICKER_MIME_TYPES, consumeChatSseStream, getChatImageValidationError, + getChatTextFileValidationError, resolveChatImageMimeType, } from "@orbit/shared/chat"; import { goalKeys, habitKeys, profileKeys, tagKeys } from "@orbit/shared/query"; @@ -152,6 +157,10 @@ export function useChatComposer({ isOnline, offlineTitle }: UseChatComposerOptio const [selectedImage, setSelectedImage] = useState(null); const [imagePreview, setImagePreview] = useState(null); + const [selectedTextFile, setSelectedTextFile] = useState<{ + name: string; + content: string; + } | null>(null); const [composerResetSignal, setComposerResetSignal] = useState(0); const hasProAccess = profile?.hasProAccess ?? false; @@ -286,6 +295,45 @@ export function useChatComposer({ isOnline, offlineTitle }: UseChatComposerOptio setImagePreview(null); }, []); + const openTextFilePicker = useCallback(async () => { + const result = await DocumentPicker.getDocumentAsync({ + type: [...CHAT_TEXT_FILE_PICKER_MIME_TYPES], + copyToCacheDirectory: true, + multiple: false, + }); + if (result.canceled) return; + + const asset = result.assets[0]; + if (!asset) return; + + const file = new FileSystemFile(asset.uri); + const validationError = getChatTextFileValidationError({ + name: asset.name, + uri: asset.uri, + fileSize: asset.size ?? file.size, + }); + if (validationError === "type") { + setSendError(t("chat.fileError")); + return; + } + if (validationError === "size") { + setSendError(t("chat.fileSizeError")); + return; + } + + try { + const content = await file.text(); + setSendError(null); + setSelectedTextFile({ name: asset.name, content }); + } catch { + setSendError(t("chat.fileReadError")); + } + }, [t]); + + const removeTextFile = useCallback(() => { + setSelectedTextFile(null); + }, []); + const handleFailedSend = useCallback( ( failureInput: StreamSendFailure, @@ -562,13 +610,21 @@ export function useChatComposer({ isOnline, offlineTitle }: UseChatComposerOptio const sendMessage = useCallback( async (content?: string) => { - const messageContent = content?.trim() ?? ""; - if ((!messageContent && !selectedImage) || isTyping) return; + const typedContent = content?.trim() ?? ""; + if ((!typedContent && !selectedImage && !selectedTextFile) || isTyping) return; if (!isOnline) { setSendError(offlineTitle); return; } + const messageContent = selectedTextFile + ? buildChatMessageWithFileContent({ + message: typedContent, + fileLabel: t("chat.fileAttached", { name: selectedTextFile.name }), + fileContent: selectedTextFile.content, + }) + : typedContent; + const attempted: AttemptedSend = { content: messageContent, image: selectedImage, @@ -578,10 +634,20 @@ export function useChatComposer({ isOnline, offlineTitle }: UseChatComposerOptio setComposerResetSignal((current) => current + 1); setSelectedImage(null); setImagePreview(null); + setSelectedTextFile(null); await performSend(attempted, false); }, - [imagePreview, isOnline, isTyping, offlineTitle, performSend, selectedImage], + [ + imagePreview, + isOnline, + isTyping, + offlineTitle, + performSend, + selectedImage, + selectedTextFile, + t, + ], ); const retryLastSend = useCallback(async () => { @@ -628,6 +694,9 @@ export function useChatComposer({ isOnline, offlineTitle }: UseChatComposerOptio showSuggestions, openFilePicker, removeImage, + selectedTextFile, + openTextFilePicker, + removeTextFile, sendMessage, retryLastSend, canRetryLastSend, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f42121cac..1464ef1bb 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -40,6 +40,7 @@ "expo-build-properties": "~55.0.14", "expo-dev-client": "55.0.23", "expo-device": "55.0.13", + "expo-document-picker": "~55.0.12", "expo-font": "55.0.6", "expo-iap": "4.3.1", "expo-image-picker": "55.0.17", diff --git a/apps/mobile/test-mocks/expo-file-system.ts b/apps/mobile/test-mocks/expo-file-system.ts index 1ca80199d..0c08a0017 100644 --- a/apps/mobile/test-mocks/expo-file-system.ts +++ b/apps/mobile/test-mocks/expo-file-system.ts @@ -1,6 +1,8 @@ export class File { readonly uri: string + size = 1024 + constructor(...segments: Array<{ uri: string } | string>) { this.uri = segments .map((segment) => (typeof segment === 'string' ? segment : segment.uri)) @@ -10,6 +12,10 @@ export class File { create() {} write() {} + + async text() { + return 'mock-file-content' + } } export const Paths = { diff --git a/apps/mobile/test-mocks/lucide-react-native.ts b/apps/mobile/test-mocks/lucide-react-native.ts index 67ed82f26..c28c56528 100644 --- a/apps/mobile/test-mocks/lucide-react-native.ts +++ b/apps/mobile/test-mocks/lucide-react-native.ts @@ -31,6 +31,7 @@ export const Clock3 = createIcon('Clock3') export const Copy = createIcon('Copy') export const Eye = createIcon('Eye') export const FastForward = createIcon('FastForward') +export const FileText = createIcon('FileText') export const Flame = createIcon('Flame') export const Gift = createIcon('Gift') export const HelpCircle = createIcon('HelpCircle') @@ -41,6 +42,7 @@ export const MessageSquare = createIcon('MessageSquare') export const MinusCircle = createIcon('MinusCircle') export const MoreVertical = createIcon('MoreVertical') export const Palette = createIcon('Palette') +export const Paperclip = createIcon('Paperclip') export const Pencil = createIcon('Pencil') export const PenSquare = createIcon('PenSquare') export const Plus = createIcon('Plus') @@ -58,6 +60,7 @@ export const Tag = createIcon('Tag') export const Trash2 = createIcon('Trash2') export const TrendingDown = createIcon('TrendingDown') export const TrendingUp = createIcon('TrendingUp') +export const Upload = createIcon('Upload') export const User = createIcon('User') export const UserPlus = createIcon('UserPlus') export const WifiOff = createIcon('WifiOff') diff --git a/apps/web/__tests__/components/chat/chat-composer-bar.test.tsx b/apps/web/__tests__/components/chat/chat-composer-bar.test.tsx index 3e7bf2d9a..7a9eacc4c 100644 --- a/apps/web/__tests__/components/chat/chat-composer-bar.test.tsx +++ b/apps/web/__tests__/components/chat/chat-composer-bar.test.tsx @@ -38,6 +38,11 @@ function baseProps() { handlePaste: vi.fn(), handleKeyDown: vi.fn(), removeImage: vi.fn(), + textFileInputRef: createRef(), + selectedTextFileName: null as string | null, + openTextFilePicker: vi.fn(), + handleTextFileSelect: vi.fn(), + removeTextFile: vi.fn(), sendMessage: vi.fn(), retryLastSend: vi.fn(), canRetryLastSend: false, @@ -147,6 +152,21 @@ describe('ChatComposerBar', () => { expect(props.openFilePicker).toHaveBeenCalled() }) + it('opens the text-file picker when the attach-file button is clicked', () => { + const props = baseProps() + render() + fireEvent.click(screen.getByLabelText('chat.attachFile')) + expect(props.openTextFilePicker).toHaveBeenCalled() + }) + + it('renders the attached text-file chip and removes it on click', () => { + const props = { ...baseProps(), selectedTextFileName: 'habits.csv' } + render() + expect(screen.getByText('habits.csv')).toBeInTheDocument() + fireEvent.click(screen.getByLabelText('chat.removeFile')) + expect(props.removeTextFile).toHaveBeenCalled() + }) + it('toggles recording from the mic button', () => { const props = baseProps() render() diff --git a/apps/web/__tests__/components/onboarding/onboarding-flow.test.tsx b/apps/web/__tests__/components/onboarding/onboarding-flow.test.tsx index 0300a86bf..d642a4794 100644 --- a/apps/web/__tests__/components/onboarding/onboarding-flow.test.tsx +++ b/apps/web/__tests__/components/onboarding/onboarding-flow.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +const mocks = vi.hoisted(() => ({ routerPush: vi.fn() })) vi.mock('next-intl', () => ({ useTranslations: () => (key: string, params?: Record) => { @@ -9,7 +11,7 @@ vi.mock('next-intl', () => ({ })) vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: vi.fn() }), + useRouter: () => ({ push: mocks.routerPush }), })) vi.mock('@tanstack/react-query', () => ({ @@ -32,7 +34,16 @@ vi.mock('@/components/onboarding/onboarding-welcome', () => ({ OnboardingWelcome: () =>
Welcome
, })) vi.mock('@/components/onboarding/onboarding-meet-astra', () => ({ - OnboardingMeetAstra: () =>
Meet Astra
, + OnboardingMeetAstra: ({ onImport }: { onImport?: () => void }) => ( +
+ Meet Astra + {onImport && ( + + )} +
+ ), })) vi.mock('@/components/onboarding/onboarding-template-packs', () => ({ OnboardingTemplatePacks: ({ @@ -89,6 +100,8 @@ import { OnboardingFlow } from '@/components/onboarding/onboarding-flow' describe('OnboardingFlow', () => { beforeEach(() => { document.body.innerHTML = '' + mocks.routerPush.mockClear() + globalThis.localStorage.clear() }) it('renders the first step (welcome)', () => { @@ -117,6 +130,16 @@ describe('OnboardingFlow', () => { expect(screen.getByTestId('step-meet-astra')).toBeInTheDocument() }) + it('imports from another app: writes the chat draft and routes into Astra', async () => { + render() + fireEvent.click(screen.getByText('onboarding.flow.begin')) + fireEvent.click(screen.getByText('onboarding.flow.meetAstra.import')) + await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith('/chat')) + expect(globalThis.localStorage.getItem('orbit-chat-draft')).toBe( + 'onboarding.flow.meetAstra.importPrompt', + ) + }) + it('advances through the create-my-own branch via interactions', () => { render() fireEvent.click(screen.getByText('onboarding.flow.begin')) diff --git a/apps/web/__tests__/hooks/use-chat-composer.test.tsx b/apps/web/__tests__/hooks/use-chat-composer.test.tsx index a649f841f..861da21c6 100644 --- a/apps/web/__tests__/hooks/use-chat-composer.test.tsx +++ b/apps/web/__tests__/hooks/use-chat-composer.test.tsx @@ -1,4 +1,5 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type { ChangeEvent } from 'react' import { renderHook, act } from '@testing-library/react' import { CHAT_STREAM_IDLE_TIMEOUT_MS } from '@orbit/shared/chat' import type { ChatResponse } from '@orbit/shared/types/chat' @@ -219,4 +220,44 @@ describe('web useChatComposer streaming send', () => { expect(result.current.sendError).toBe('chat.limitReachedError') expect(result.current.canRetryLastSend).toBe(false) }) + + it('folds an attached text file into the sent user message', async () => { + mocks.fetch.mockResolvedValue( + sseResponse(finalFrame(makeChatResponse({ aiMessage: 'Imported' }))), + ) + const { result } = renderHook(() => useChatComposer()) + const file = new File(['Run\nRead'], 'habits.csv', { type: 'text/csv' }) + + await act(async () => { + await result.current.handleTextFileSelect({ + target: { files: [file], value: '' }, + } as unknown as ChangeEvent) + }) + expect(result.current.selectedTextFileName).toBe('habits.csv') + + await act(async () => { + await result.current.sendMessage() + }) + + const userMessage = useChatStore + .getState() + .messages.find((message) => message.role === 'user') + expect(userMessage?.content).toContain('Run\nRead') + expect(userMessage?.content).toContain('chat.fileAttached') + expect(result.current.selectedTextFileName).toBeNull() + }) + + it('surfaces the i18n error for an unsupported attachment type', async () => { + const { result } = renderHook(() => useChatComposer()) + const file = new File(['nope'], 'photo.png', { type: 'image/png' }) + + await act(async () => { + await result.current.handleTextFileSelect({ + target: { files: [file], value: '' }, + } as unknown as ChangeEvent) + }) + + expect(result.current.sendError).toBe('chat.fileError') + expect(result.current.selectedTextFileName).toBeNull() + }) }) diff --git a/apps/web/app/(chat)/chat/chat-composer-bar.tsx b/apps/web/app/(chat)/chat/chat-composer-bar.tsx index b7067eece..b99a715c0 100644 --- a/apps/web/app/(chat)/chat/chat-composer-bar.tsx +++ b/apps/web/app/(chat)/chat/chat-composer-bar.tsx @@ -6,9 +6,22 @@ import type { KeyboardEvent, RefObject, } from 'react' -import { Mic, Square, ArrowUp, X, Crown, Lock, Image as ImageIcon } from 'lucide-react' +import { + Mic, + Square, + ArrowUp, + X, + Crown, + Lock, + Image as ImageIcon, + Paperclip, + FileText, +} from 'lucide-react' import { useTranslations } from 'next-intl' -import { CHAT_VISUALIZER_BAR_OFFSETS as VISUALIZER_BAR_OFFSETS } from '@orbit/shared/chat' +import { + CHAT_TEXT_FILE_WEB_ACCEPT, + CHAT_VISUALIZER_BAR_OFFSETS as VISUALIZER_BAR_OFFSETS, +} from '@orbit/shared/chat' import { InfoCard } from '@/components/ui/info-card' import { LocalImage } from '@/components/ui/local-image' import { PillButton } from '@/components/ui/pill-button' @@ -39,6 +52,11 @@ interface ChatComposerBarProps { handlePaste: (event: ClipboardEvent) => void handleKeyDown: (event: KeyboardEvent) => void removeImage: () => void + textFileInputRef: RefObject + selectedTextFileName: string | null + openTextFilePicker: () => void + handleTextFileSelect: (event: ChangeEvent) => void + removeTextFile: () => void sendMessage: (content?: string) => void retryLastSend: () => void canRetryLastSend: boolean @@ -51,6 +69,8 @@ interface ChatComposerNoticesProps { retryLastSend: () => void imagePreview: string | null removeImage: () => void + selectedTextFileName: string | null + removeTextFile: () => void hasMessages: boolean starterChips: string[] sendMessage: (content?: string) => void @@ -62,6 +82,8 @@ function ChatComposerNotices({ retryLastSend, imagePreview, removeImage, + selectedTextFileName, + removeTextFile, hasMessages, starterChips, sendMessage, @@ -122,6 +144,46 @@ function ChatComposerNotices({ )} + {selectedTextFileName && ( +
+
+
+
+ )} + {hasMessages && starterChips.length > 0 && (
void canSend: boolean openFilePicker: () => void + openTextFilePicker: () => void handlePaste: (event: ClipboardEvent) => void handleKeyDown: (event: KeyboardEvent) => void sendMessage: (content?: string) => void @@ -248,6 +311,7 @@ function ChatTextInputRow({ toggleRecording, canSend, openFilePicker, + openTextFilePicker, handlePaste, handleKeyDown, sendMessage, @@ -297,6 +361,17 @@ function ChatTextInputRow({ )} + {!limitLocked && ( + + )} {!limitLocked && (
+ + {onImport && ( + + )} ) } diff --git a/apps/web/hooks/use-chat-composer.ts b/apps/web/hooks/use-chat-composer.ts index e702e1a4f..2a770ab4b 100644 --- a/apps/web/hooks/use-chat-composer.ts +++ b/apps/web/hooks/use-chat-composer.ts @@ -17,6 +17,7 @@ import type { ChatResponse } from '@orbit/shared/types/chat' import type { Profile } from '@orbit/shared/types/profile' import type { AgentExecuteOperationResponse } from '@orbit/shared/types/ai' import { + buildChatMessageWithFileContent, CHAT_STARTER_CHIP_KEYS, CHAT_STREAM_IDLE_TIMEOUT_MS, consumeChatSseStream, @@ -40,6 +41,7 @@ import { useSpeechToText } from '@/hooks/use-speech-to-text' import { useChatStore } from '@/stores/chat-store' import { useProfile } from '@/hooks/use-profile' import { useChatImageAttachment } from '@/hooks/use-chat-image-attachment' +import { useChatTextFileAttachment } from '@/hooks/use-chat-text-file-attachment' import { useChatPendingOperations } from '@/hooks/use-chat-pending-operations' interface AttemptedSend { @@ -129,6 +131,14 @@ export function useChatComposer() { clearImage, } = useChatImageAttachment(setSendError) + const { + textFileInputRef, + selectedTextFile, + openTextFilePicker, + handleTextFileSelect, + removeTextFile, + } = useChatTextFileAttachment(setSendError) + if (speechError !== previousSpeechError) { setPreviousSpeechError(speechError) if (speechError) { @@ -141,7 +151,9 @@ export function useChatComposer() { const aiMessagesLimit = profile?.aiMessagesLimit ?? 20 const atMessageLimit = !hasProAccess && aiMessagesUsed >= aiMessagesLimit const canSend = - (input.trim().length > 0 || selectedImage !== null) && !isTyping && !atMessageLimit + (input.trim().length > 0 || selectedImage !== null || selectedTextFile !== null) && + !isTyping && + !atMessageLimit const showSuggestions = messages.length === 0 && !isTyping const starterChips = useMemo( @@ -478,8 +490,16 @@ export function useChatComposer() { const sendMessage = useCallback( async (content?: string) => { - const messageContent = content || input.trim() - if ((!messageContent && !selectedImage) || isTyping) return + const typedContent = content || input.trim() + if ((!typedContent && !selectedImage && !selectedTextFile) || isTyping) return + + const messageContent = selectedTextFile + ? buildChatMessageWithFileContent({ + message: typedContent, + fileLabel: t('chat.fileAttached', { name: selectedTextFile.name }), + fileContent: selectedTextFile.content, + }) + : typedContent const attempted: AttemptedSend = { content: messageContent, @@ -489,10 +509,21 @@ export function useChatComposer() { setInput('') clearImage() + removeTextFile() await performSend(attempted, false) }, - [clearImage, imagePreview, input, isTyping, performSend, selectedImage], + [ + clearImage, + imagePreview, + input, + isTyping, + performSend, + removeTextFile, + selectedImage, + selectedTextFile, + t, + ], ) const retryLastSend = useCallback(async () => { @@ -542,6 +573,11 @@ export function useChatComposer() { handleFileSelect, handlePaste, removeImage, + textFileInputRef, + selectedTextFileName: selectedTextFile?.name ?? null, + openTextFilePicker, + handleTextFileSelect, + removeTextFile, sendMessage, retryLastSend, canRetryLastSend, diff --git a/apps/web/hooks/use-chat-text-file-attachment.ts b/apps/web/hooks/use-chat-text-file-attachment.ts new file mode 100644 index 000000000..b7644bf7f --- /dev/null +++ b/apps/web/hooks/use-chat-text-file-attachment.ts @@ -0,0 +1,66 @@ +'use client' + +import { useRef, useState, type ChangeEvent } from 'react' +import { useTranslations } from 'next-intl' +import { getChatTextFileValidationError } from '@orbit/shared/chat' + +interface SelectedChatTextFile { + name: string + content: string +} + +/** + * Manages the chat composer's text-file attachment: hidden file input, picker, + * validation, and reading the file's contents client-side via `File.text()`. + * The contents ride into the outgoing message as plain chat text (no upload, no + * backend change), mirroring the image-attachment hook. Drives the composer's + * send error via `setSendError` (cleared on a valid pick, set on a failure). + */ +export function useChatTextFileAttachment(setSendError: (message: string | null) => void) { + const t = useTranslations() + const textFileInputRef = useRef(null) + const [selectedTextFile, setSelectedTextFile] = useState(null) + + function openTextFilePicker() { + textFileInputRef.current?.click() + } + + async function handleTextFileSelect(event: ChangeEvent) { + const file = event.target.files?.[0] + event.target.value = '' + if (!file) return + + const validationError = getChatTextFileValidationError({ + name: file.name, + fileSize: file.size, + }) + if (validationError === 'type') { + setSendError(t('chat.fileError')) + return + } + if (validationError === 'size') { + setSendError(t('chat.fileSizeError')) + return + } + + try { + const content = await file.text() + setSendError(null) + setSelectedTextFile({ name: file.name, content }) + } catch { + setSendError(t('chat.fileReadError')) + } + } + + function removeTextFile() { + setSelectedTextFile(null) + } + + return { + textFileInputRef, + selectedTextFile, + openTextFilePicker, + handleTextFileSelect, + removeTextFile, + } +} diff --git a/package-lock.json b/package-lock.json index e345b3b30..de621e974 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "expo-build-properties": "~55.0.14", "expo-dev-client": "55.0.23", "expo-device": "55.0.13", + "expo-document-picker": "~55.0.12", "expo-font": "55.0.6", "expo-iap": "4.3.1", "expo-image-picker": "55.0.17", @@ -11975,6 +11976,15 @@ "expo": "*" } }, + "node_modules/expo-document-picker": { + "version": "55.0.14", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-55.0.14.tgz", + "integrity": "sha512-Jfw7q5evNc1ebM/gZQ0bmG1t56QdHurbZ6IF8NwSnf/F5B4qjS97OeCfdsq+Q5JLrTmqSIkvYhCZwRYZzuX0Fw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-eas-client": { "version": "55.0.5", "resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-55.0.5.tgz", diff --git a/packages/shared/src/__tests__/chat.test.ts b/packages/shared/src/__tests__/chat.test.ts index 9d55970a6..bfa85a795 100644 --- a/packages/shared/src/__tests__/chat.test.ts +++ b/packages/shared/src/__tests__/chat.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { + buildChatMessageWithFileContent, getChatImageValidationError, + getChatTextFileValidationError, resolveChatImageMimeType, } from '../chat' @@ -46,3 +48,52 @@ describe('getChatImageValidationError', () => { ).toBe('size') }) }) + +describe('getChatTextFileValidationError', () => { + it('accepts supported text files under the size limit, case-insensitively', () => { + expect(getChatTextFileValidationError({ name: 'habits.csv', fileSize: 2048 })).toBeNull() + expect(getChatTextFileValidationError({ name: 'export.JSON', fileSize: 2048 })).toBeNull() + expect(getChatTextFileValidationError({ name: 'notes.md', fileSize: 2048 })).toBeNull() + expect(getChatTextFileValidationError({ name: 'list.txt', fileSize: 2048 })).toBeNull() + }) + + it('infers the allowed extension from the uri when the name is missing', () => { + expect( + getChatTextFileValidationError({ uri: 'file:///tmp/export.csv', fileSize: 2048 }), + ).toBeNull() + }) + + it('rejects unsupported or extension-less files', () => { + expect(getChatTextFileValidationError({ name: 'photo.png', fileSize: 2048 })).toBe('type') + expect(getChatTextFileValidationError({ name: 'report.pdf', fileSize: 2048 })).toBe('type') + expect(getChatTextFileValidationError({ name: 'noextension', fileSize: 2048 })).toBe('type') + }) + + it('rejects text files above the max size', () => { + expect(getChatTextFileValidationError({ name: 'huge.csv', fileSize: 2 * 1024 * 1024 })).toBe( + 'size', + ) + }) +}) + +describe('buildChatMessageWithFileContent', () => { + it('appends the file block beneath the typed message', () => { + expect( + buildChatMessageWithFileContent({ + message: 'Import these please', + fileLabel: 'Attached file "habits.csv":', + fileContent: 'Run\nRead', + }), + ).toBe('Import these please\n\nAttached file "habits.csv":\nRun\nRead') + }) + + it('returns only the file block when no message is typed', () => { + expect( + buildChatMessageWithFileContent({ + message: ' ', + fileLabel: 'Attached file "list.txt":', + fileContent: 'Meditate', + }), + ).toBe('Attached file "list.txt":\nMeditate') + }) +}) diff --git a/packages/shared/src/chat/index.ts b/packages/shared/src/chat/index.ts index 847ffd49f..aeb3aca3a 100644 --- a/packages/shared/src/chat/index.ts +++ b/packages/shared/src/chat/index.ts @@ -79,6 +79,75 @@ export function getChatImageValidationError( return null } +const MAX_CHAT_TEXT_FILE_SIZE_BYTES = 1024 * 1024 + +const CHAT_TEXT_FILE_EXTENSIONS = ['.csv', '.json', '.txt', '.md'] as const + +/** `accept` attribute value for the web chat text-file ``. */ +export const CHAT_TEXT_FILE_WEB_ACCEPT = + '.csv,.json,.txt,.md,text/csv,application/json,text/plain,text/markdown' + +/** MIME-type filters for the mobile `expo-document-picker` text-file picker. */ +export const CHAT_TEXT_FILE_PICKER_MIME_TYPES = ['text/*', 'application/json'] as const + +type ChatTextFileValidationError = 'type' | 'size' + +interface ChatTextFileCandidate { + name?: string | null + uri?: string | null + fileSize?: number | null +} + +function hasAllowedChatTextFileExtension(value: string | null | undefined): boolean { + if (!value) return false + + const normalized = value.trim().toLowerCase() + return CHAT_TEXT_FILE_EXTENSIONS.some((extension) => normalized.endsWith(extension)) +} + +/** + * Validates a chat text-file attachment by extension and size, mirroring + * {@link getChatImageValidationError}. Returns `'type'` for an unsupported + * extension, `'size'` for a file over {@link MAX_CHAT_TEXT_FILE_SIZE_BYTES}, or + * `null` when valid. The gate is extension-based because picker/browser MIME + * types are unreliable for `.csv`/`.md`, and the contents ride to Astra as plain + * chat text (no upload, no backend change). + */ +export function getChatTextFileValidationError( + candidate: ChatTextFileCandidate, +): ChatTextFileValidationError | null { + const hasAllowedType = + hasAllowedChatTextFileExtension(candidate.name) || + hasAllowedChatTextFileExtension(candidate.uri) + if (!hasAllowedType) return 'type' + + if ( + typeof candidate.fileSize === 'number' && + candidate.fileSize > MAX_CHAT_TEXT_FILE_SIZE_BYTES + ) { + return 'size' + } + + return null +} + +/** + * Folds an attached text file's contents into the outgoing chat message so the + * existing Astra pipeline parses it as plain text. `fileLabel` is the + * already-localized "Attached file ..." heading; both platforms call this so the + * framing Astra receives stays identical. Returns the file block alone when the + * user typed no accompanying message. + */ +export function buildChatMessageWithFileContent(params: { + message: string + fileLabel: string + fileContent: string +}): string { + const trimmedMessage = params.message.trim() + const fileBlock = `${params.fileLabel}\n${params.fileContent.trim()}` + return trimmedMessage ? `${trimmedMessage}\n\n${fileBlock}` : fileBlock +} + const COMPLETE_HABIT_LIST_DIRECTIVE = /\[\[orbit:habits:(?:today|all)\]\]/gi const TRAILING_HABIT_LIST_DIRECTIVE = /\n?\[\[orbit:habits:?[a-z]*\]?\]?\s*$/i diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index 718566fa8..667c3ded5 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -720,6 +720,12 @@ "imageError": "Only JPEG, PNG, and WebP images are supported.", "imageSizeError": "Image must be under 20MB.", "imagePermissionError": "Photo library access is required to attach images.", + "attachFile": "Attach file", + "removeFile": "Remove file", + "fileError": "Only .csv, .json, .txt, and .md files are supported.", + "fileSizeError": "File must be under 1 MB.", + "fileReadError": "Couldn't read that file. Please try again.", + "fileAttached": "Attached file \"{name}\":", "sendError": "Failed to send message. Please try again.", "timeoutError": "The request timed out. Please try again.", "limitReachedError": "You've reached your monthly AI message limit.", @@ -1230,7 +1236,9 @@ "back": "Back", "meetAstra": { "title": "Meet Astra.", - "subtitle": "Orbit's assistant. It can create habits, recap your day, plan ahead, and reflect on what's drifting. Ask plainly. It listens." + "subtitle": "Orbit's assistant. It can create habits, recap your day, plan ahead, and reflect on what's drifting. Ask plainly. It listens.", + "import": "Import from another app", + "importPrompt": "Help me import my habits from another app. I'll attach a file or paste my list, and you create the habits." }, "welcome": { "title": "Welcome to Orbit", diff --git a/packages/shared/src/i18n/pt-BR.json b/packages/shared/src/i18n/pt-BR.json index 60762c89b..18d8d8889 100644 --- a/packages/shared/src/i18n/pt-BR.json +++ b/packages/shared/src/i18n/pt-BR.json @@ -720,6 +720,12 @@ "imageError": "Só aceitamos imagens JPEG, PNG e WebP.", "imageSizeError": "A imagem precisa ter menos de 20 MB.", "imagePermissionError": "Permita o acesso às fotos para anexar imagens.", + "attachFile": "Anexar arquivo", + "removeFile": "Remover arquivo", + "fileError": "Só aceitamos arquivos .csv, .json, .txt e .md.", + "fileSizeError": "O arquivo precisa ter menos de 1 MB.", + "fileReadError": "Não foi possível ler esse arquivo. Tente de novo.", + "fileAttached": "Arquivo anexado \"{name}\":", "sendError": "Não foi possível enviar a mensagem. Tente de novo.", "timeoutError": "A resposta demorou demais. Tente de novo.", "limitReachedError": "Você atingiu o limite mensal de mensagens de IA.", @@ -1230,7 +1236,9 @@ "back": "Voltar", "meetAstra": { "title": "Conheça a Astra.", - "subtitle": "A assistente do Orbit. Ela cria hábitos, resume o seu dia, planeja o que vem e percebe o que saiu do eixo. Fale com naturalidade. Ela escuta." + "subtitle": "A assistente do Orbit. Ela cria hábitos, resume o seu dia, planeja o que vem e percebe o que saiu do eixo. Fale com naturalidade. Ela escuta.", + "import": "Importar de outro app", + "importPrompt": "Me ajude a importar meus hábitos de outro app. Vou anexar um arquivo ou colar minha lista, e você cria os hábitos." }, "welcome": { "title": "Boas-vindas ao Orbit",