diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index 385d52792d..61edb76be9 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -1,8 +1,10 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +/* eslint-disable max-lines -- New-session screen bundles closely related prompt/toolbar/repository concerns in a single component to keep navigation props colocated. */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, type LayoutChangeEvent, Platform, + Pressable, ScrollView, TextInput, type TextStyle, @@ -13,9 +15,11 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { generateMessageId } from 'cloud-agent-sdk/message-id'; import * as Haptics from 'expo-haptics'; import * as WebBrowser from 'expo-web-browser'; -import { ExternalLink, RefreshCw } from 'lucide-react-native'; +import { ExternalLink, Paperclip, RefreshCw } from 'lucide-react-native'; import { toast } from 'sonner-native'; +import { AttachmentPreviewStrip } from '@/components/agents/attachment-preview-strip'; +import { pickAgentAttachments } from '@/components/agents/attachment-picker'; import { ChatToolbar } from '@/components/agents/chat-toolbar'; import { type AgentMode } from '@/components/agents/mode-selector'; import { RepoSelector } from '@/components/agents/repo-selector'; @@ -28,16 +32,25 @@ import { getGitHubIntegrationUrl, shouldShowGitHubIntegrationPrompt, } from '@/lib/agent-github-integration'; +import { AGENT_ATTACHMENT_MAX_FILES } from '@/lib/agent-attachments/constants'; +import { + type AgentAttachmentWire, + useAgentAttachmentUpload, +} from '@/lib/agent-attachments/use-agent-attachment-upload'; import { WEB_BASE_URL } from '@/lib/config'; import { useAvailableModels } from '@/lib/hooks/use-available-models'; +import { contextKey, resolveModelForContext } from '@/lib/hooks/agent-model-preference'; +import { usePersistedAgentModel } from '@/lib/hooks/use-persisted-agent-model'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { trpcClient, useTRPC } from '@/lib/trpc'; const PROMPT_INPUT_DEFAULT_LINES = 3; const PROMPT_INPUT_MAX_LINES = 6; const PROMPT_INPUT_LINE_HEIGHT = 24; -const PROMPT_INPUT_VERTICAL_PADDING = 32; -const PROMPT_INPUT_HORIZONTAL_PADDING = Platform.OS === 'android' ? 48 : 32; +// Must mirror the TextInput's actual padding: py-2 (16 total) and px-2 on +// iOS (16 total) / the 24pt-per-side Android inset (48 total). +const PROMPT_INPUT_VERTICAL_PADDING = 16; +const PROMPT_INPUT_HORIZONTAL_PADDING = Platform.OS === 'android' ? 48 : 16; const PROMPT_INPUT_ANDROID_HORIZONTAL_INSET = 24; const PROMPT_INPUT_MIN_HEIGHT = PROMPT_INPUT_LINE_HEIGHT * PROMPT_INPUT_DEFAULT_LINES + PROMPT_INPUT_VERTICAL_PADDING; @@ -79,17 +92,41 @@ export default function NewSessionScreen() { // ── Models ─────────────────────────────────────────────────────── const { models } = useAvailableModels(organizationId); + const { + hasLoaded: modelPrefLoaded, + stored: storedModelPref, + saveModel, + } = usePersistedAgentModel(); + const attachments = useAgentAttachmentUpload({ organizationId }); - // Auto-select first model when models load + // Auto-select first model when models load, preferring the persisted preference const hasAutoSelectedModel = useRef(false); - if (models.length > 0 && !model && !hasAutoSelectedModel.current) { - const firstModel = models[0]; - if (firstModel) { + useEffect(() => { + if (hasAutoSelectedModel.current) { + return; + } + // Never overwrite a model the user already picked manually. + if (model) { hasAutoSelectedModel.current = true; - setModel(firstModel.id); - setVariant(firstModel.variants[0] ?? ''); + return; + } + if (models.length === 0 || !modelPrefLoaded) { + return; } - } + + const persisted = resolveModelForContext(storedModelPref, contextKey(organizationId), models); + if (persisted) { + setModel(persisted.model); + setVariant(persisted.variant); + } else { + const firstModel = models[0]; + if (firstModel) { + setModel(firstModel.id); + setVariant(firstModel.variants[0] ?? ''); + } + } + hasAutoSelectedModel.current = true; + }, [models, modelPrefLoaded, storedModelPref, organizationId, model]); // ── Repositories ───────────────────────────────────────────────── const trpc = useTRPC(); @@ -126,10 +163,14 @@ export default function NewSessionScreen() { }, [repoData]); // ── Handlers ───────────────────────────────────────────────────── - const handleModelSelect = useCallback((modelId: string, newVariant: string) => { - setModel(modelId); - setVariant(newVariant); - }, []); + const handleModelSelect = useCallback( + (modelId: string, newVariant: string) => { + setModel(modelId); + setVariant(newVariant); + saveModel(organizationId, { model: modelId, variant: newVariant }); + }, + [organizationId, saveModel] + ); const handleOpenGitHubIntegration = useCallback(async () => { try { @@ -142,15 +183,34 @@ export default function NewSessionScreen() { const handleCreate = useCallback(async () => { const prompt = promptRef.current.trim(); + // The backend requires a non-empty prompt even when attachments are present. if (!prompt || !selectedRepo || !model) { return; } + if (attachments.isUploading) { + toast.error('Wait for attachments to finish uploading.'); + return; + } + if (prompt.startsWith('/') && attachments.attachments.length > 0) { + toast.error('Attachments cannot be sent with slash commands.'); + return; + } setIsCreating(true); try { const initialMessageId = generateMessageId(); - const baseInput = { + const baseInput: { + prompt: string; + initialMessageId: string; + mode: AgentMode; + model: string; + variant: string | undefined; + githubRepo: string; + autoCommit: boolean; + autoInitiate: boolean; + attachments?: AgentAttachmentWire; + } = { prompt, initialMessageId, mode, @@ -160,6 +220,10 @@ export default function NewSessionScreen() { autoCommit: true, autoInitiate: true, }; + const wireAttachments = attachments.toWirePayload(); + if (wireAttachments) { + baseInput.attachments = wireAttachments; + } const result = organizationId ? await trpcClient.organizations.cloudAgentNext.prepareSession.mutate({ @@ -173,10 +237,6 @@ export default function NewSessionScreen() { const path = organizationId ? `/(app)/agent-chat/${result.kiloSessionId}?organizationId=${organizationId}` : `/(app)/agent-chat/${result.kiloSessionId}`; - // router.replace() crashes on Android Fabric (react-native-screens - // "addViewAt: View already has a parent"). Work around it by pushing - // first, then removing this screen from the stack on the next frame - // so the back button goes straight to the session list. router.push(path as Href); requestAnimationFrame(() => { navigation.dispatch(state => { @@ -193,10 +253,26 @@ export default function NewSessionScreen() { } finally { setIsCreating(false); } - }, [selectedRepo, model, mode, variant, organizationId, queryClient, trpc, router, navigation]); + }, [ + selectedRepo, + model, + mode, + variant, + organizationId, + queryClient, + trpc, + router, + navigation, + attachments, + ]); const canStart = hasPrompt && selectedRepo.length > 0 && model.length > 0 && !isCreating; + const { addCandidates } = attachments; + const handleAddAttachment = useCallback(async () => { + addCandidates(await pickAgentAttachments()); + }, [addCandidates]); + function handlePromptInputLayout(event: LayoutChangeEvent) { const nextWidth = Math.max(Math.round(event.nativeEvent.layout.width), 0); setPromptInputWidth(current => (current === nextWidth ? current : nextWidth)); @@ -213,29 +289,49 @@ export default function NewSessionScreen() { automaticallyAdjustKeyboardInsets > - {promptMeasure.measureElement} - { - promptRef.current = text; - promptMeasure.setText(text); - setHasPrompt(text.trim().length > 0); + { + attachments.removeAttachment(id); }} - onLayout={handlePromptInputLayout} - scrollEnabled={promptMeasure.height >= PROMPT_INPUT_MAX_HEIGHT} - editable={!isCreating} - autoFocus /> + + { + void handleAddAttachment(); + }} + disabled={isCreating || attachments.attachments.length >= AGENT_ATTACHMENT_MAX_FILES} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + className="h-9 w-9 items-center justify-center rounded-full active:opacity-70" + accessibilityRole="button" + accessibilityLabel="Add attachment" + > + + + {promptMeasure.measureElement} + { + promptRef.current = text; + promptMeasure.setText(text); + setHasPrompt(text.trim().length > 0); + }} + onLayout={handlePromptInputLayout} + scrollEnabled={promptMeasure.height >= PROMPT_INPUT_MAX_HEIGHT} + editable={!isCreating} + autoFocus + /> + void Linking.openSettings() }, + ]); +} + +function mimeTypeForExtension(extension: AgentAttachmentExtension): string { + return AGENT_ATTACHMENT_MIME_BY_EXTENSION[extension]; +} + +function normalizeImageAsset(asset: { + uri: string; + fileName?: string | null; + mimeType?: string | null; + fileSize?: number | null; +}): AgentAttachmentCandidate { + const name = asset.fileName ?? `image.${(asset.mimeType ?? 'image/png').split('/')[1] ?? 'png'}`; + return { + name, + uri: asset.uri, + mimeType: asset.mimeType ?? undefined, + size: asset.fileSize ?? undefined, + }; +} + +function normalizeDocumentAsset(asset: { + uri: string; + name: string; + mimeType?: string; + size?: number; +}): AgentAttachmentCandidate { + const dot = asset.name.lastIndexOf('.'); + const ext = + dot > 0 ? (asset.name.slice(dot + 1).toLowerCase() as AgentAttachmentExtension) : null; + const mimeType = asset.mimeType ?? (ext ? mimeTypeForExtension(ext) : undefined); + return { + name: asset.name, + uri: asset.uri, + mimeType, + size: asset.size, + }; +} + +async function pickAgentCameraImage(): Promise { + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (!permission.granted) { + showPermissionSettingsAlert({ + title: 'Camera Access Disabled', + message: 'Allow camera access in Settings to take a photo.', + }); + return []; + } + const result = await ImagePicker.launchCameraAsync(IMAGE_PICKER_OPTIONS); + if (result.canceled) { + return []; + } + return result.assets.map(normalizeImageAsset).filter(asset => { + const classified = classifyAttachment(asset); + return classified.ok; + }); +} + +async function pickAgentLibraryImages(): Promise { + const result = await ImagePicker.launchImageLibraryAsync({ + ...IMAGE_PICKER_OPTIONS, + allowsMultipleSelection: true, + }); + if (result.canceled) { + return []; + } + return result.assets.map(normalizeImageAsset).filter(asset => { + const classified = classifyAttachment(asset); + return classified.ok; + }); +} + +async function pickAgentDocuments(): Promise { + const result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + multiple: true, + type: '*/*', + }); + if (result.canceled) { + return []; + } + return result.assets.map(normalizeDocumentAsset).filter(asset => { + const classified = classifyAttachment(asset); + return classified.ok; + }); +} + +type AttachmentSource = 'camera' | 'library' | 'files'; + +async function pickFromSource(source: AttachmentSource): Promise { + if (source === 'camera') { + return pickAgentCameraImage(); + } + if (source === 'library') { + return pickAgentLibraryImages(); + } + return pickAgentDocuments(); +} + +export function pickAgentAttachments(): Promise { + return new Promise(resolve => { + let settled = false; + const settle = (value: AgentAttachmentCandidate[]) => { + if (!settled) { + settled = true; + resolve(value); + } + }; + const handle = async (source: AttachmentSource) => { + const result = await pickFromSource(source); + settle(result); + }; + Alert.alert( + 'Add attachment', + 'Choose a source', + [ + { + text: 'Camera', + onPress: () => { + void handle('camera'); + }, + }, + { + text: 'Photo Library', + onPress: () => { + void handle('library'); + }, + }, + { + text: 'Files', + onPress: () => { + void handle('files'); + }, + }, + { + text: 'Cancel', + style: 'cancel', + onPress: () => { + settle([]); + }, + }, + ], + { + cancelable: true, + onDismiss: () => { + settle([]); + }, + } + ); + }); +} diff --git a/apps/mobile/src/components/agents/attachment-preview-strip.tsx b/apps/mobile/src/components/agents/attachment-preview-strip.tsx new file mode 100644 index 0000000000..9f4f2b1ee5 --- /dev/null +++ b/apps/mobile/src/components/agents/attachment-preview-strip.tsx @@ -0,0 +1,106 @@ +import { formatFileSize } from '@kilocode/kilo-chat'; +import { ActivityIndicator, Pressable, ScrollView, View } from 'react-native'; +import { AlertCircle, File as FileIcon, X } from 'lucide-react-native'; + +import { Image } from '@/components/ui/image'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { cn } from '@/lib/utils'; +import { type AgentAttachment } from '@/lib/agent-attachments/use-agent-attachment-upload'; + +type Props = { + attachments: AgentAttachment[]; + onRemove: (id: string) => void; +}; + +const REMOVE_HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 } as const; + +function AttachmentChip({ + attachment, + onRemove, +}: { + attachment: AgentAttachment; + onRemove: () => void; +}) { + const colors = useThemeColors(); + const isImage = attachment.kind === 'image'; + const isUploading = attachment.status === 'pending' || attachment.status === 'uploading'; + const isErrored = attachment.status === 'error'; + + return ( + + {isImage ? ( + + ) : ( + + {isErrored ? ( + + ) : ( + + )} + + + {attachment.filename} + + + {formatFileSize(attachment.size)} + + + + )} + + {isImage && isUploading ? ( + + + + ) : null} + + + + + + ); +} + +export function AttachmentPreviewStrip({ attachments, onRemove }: Readonly) { + if (attachments.length === 0) { + return null; + } + return ( + + {attachments.map(attachment => ( + { + onRemove(attachment.id); + }} + /> + ))} + + ); +} diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index 09b5228811..135080449c 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -1,6 +1,6 @@ import * as Haptics from 'expo-haptics'; -import { ArrowUp, Square } from 'lucide-react-native'; -import { useRef, useState } from 'react'; +import { ArrowUp, Paperclip, Square } from 'lucide-react-native'; +import { useCallback, useRef, useState } from 'react'; import { Keyboard, type LayoutChangeEvent, @@ -10,11 +10,19 @@ import { View, } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { toast } from 'sonner-native'; +import { AttachmentPreviewStrip } from '@/components/agents/attachment-preview-strip'; import { ChatToolbar } from '@/components/agents/chat-toolbar'; import { type AgentMode } from '@/components/agents/mode-selector'; +import { pickAgentAttachments } from '@/components/agents/attachment-picker'; import { useTextHeight } from '@/components/agents/use-text-height'; import { BlurBar } from '@/components/ui/blur-bar'; +import { AGENT_ATTACHMENT_MAX_FILES } from '@/lib/agent-attachments/constants'; +import { + type AgentAttachmentWire, + useAgentAttachmentUpload, +} from '@/lib/agent-attachments/use-agent-attachment-upload'; import { type ModelOption } from '@/lib/hooks/use-available-models'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -26,8 +34,10 @@ const TEXT_INPUT_MIN_HEIGHT = TEXT_INPUT_LINE_HEIGHT + TEXT_INPUT_VERTICAL_PADDI const TEXT_INPUT_MAX_HEIGHT = TEXT_INPUT_LINE_HEIGHT * TEXT_INPUT_MAX_LINES + TEXT_INPUT_VERTICAL_PADDING; +const PAPERCLIP_HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 } as const; + type ChatComposerProps = { - onSend: (text: string) => void | Promise; + onSend: (text: string, attachments?: AgentAttachmentWire) => void | Promise; onStop?: () => void | Promise; disabled?: boolean; isStreaming?: boolean; @@ -38,6 +48,9 @@ type ChatComposerProps = { variant: string; modelOptions: ModelOption[]; onModelSelect: (modelId: string, variant: string) => void; + organizationId?: string; + /** Only Cloud Agent sessions can receive attachments. */ + attachmentsEnabled?: boolean; }; export function ChatComposer({ @@ -52,6 +65,8 @@ export function ChatComposer({ variant, modelOptions, onModelSelect, + organizationId, + attachmentsEnabled = true, }: Readonly) { const colors = useThemeColors(); const textRef = useRef(''); @@ -59,6 +74,7 @@ export function ChatComposer({ const [hasText, setHasText] = useState(false); const [inputWidth, setInputWidth] = useState(0); const [isFocused, setIsFocused] = useState(false); + const upload = useAgentAttachmentUpload({ organizationId }); const measure = useTextHeight({ minHeight: TEXT_INPUT_MIN_HEIGHT, @@ -69,8 +85,9 @@ export function ChatComposer({ lineHeight: TEXT_INPUT_LINE_HEIGHT, }); + // The backend requires a non-empty prompt even when attachments are present. const canSend = hasText && !disabled && !isStreaming; - const showToolbar = isFocused || hasText; + const showToolbar = isFocused || hasText || upload.attachments.length > 0; const toolbarDisabled = disabled || isStreaming; function handleChangeText(value: string) { @@ -84,13 +101,23 @@ export function ChatComposer({ if (!trimmed || !canSend) { return; } + if (upload.isUploading) { + toast.error('Wait for attachments to finish uploading.'); + return; + } + if (trimmed.startsWith('/') && upload.attachments.length > 0) { + toast.error('Attachments cannot be sent with slash commands.'); + return; + } void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - void onSend(trimmed); + const payload = upload.toWirePayload(); + void onSend(trimmed, payload); textRef.current = ''; setHasText(false); measure.reset(); inputRef.current?.clear(); + upload.reset(); Keyboard.dismiss(); } @@ -104,6 +131,12 @@ export function ChatComposer({ setInputWidth(current => (current === nextWidth ? current : nextWidth)); } + const { addCandidates, removeAttachment } = upload; + + const handleAddAttachment = useCallback(async () => { + addCandidates(await pickAgentAttachments()); + }, [addCandidates]); + const textInputStyle: TextStyle = { color: colors.foreground, fontSize: 16, @@ -134,7 +167,26 @@ export function ChatComposer({ ) : null} + {attachmentsEnabled ? ( + + ) : null} + + {attachmentsEnabled ? ( + { + void handleAddAttachment(); + }} + disabled={toolbarDisabled || upload.attachments.length >= AGENT_ATTACHMENT_MAX_FILES} + hitSlop={PAPERCLIP_HIT_SLOP} + className="h-8 w-8 items-center justify-center rounded-full active:opacity-70" + accessibilityRole="button" + accessibilityLabel="Add attachment" + > + + + ) : null} + ) { + const colors = useThemeColors(); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); const modeSelector = ; const modelSelector = ( {order === 'model-first' ? modelSelector : modeSelector} {order === 'model-first' ? modeSelector : modelSelector} + + {showReasoningSettings ? ( + <> + { + if (!disabled) { + setIsSettingsOpen(true); + } + }} + disabled={disabled} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + className="h-8 w-8 items-center justify-center rounded-full active:opacity-70" + accessibilityRole="button" + accessibilityLabel="Reasoning settings" + > + + + { + setIsSettingsOpen(false); + }} + /> + + ) : null} ); } diff --git a/apps/mobile/src/components/agents/collect-copyable-text.ts b/apps/mobile/src/components/agents/collect-copyable-text.ts new file mode 100644 index 0000000000..e41a98e3e2 --- /dev/null +++ b/apps/mobile/src/components/agents/collect-copyable-text.ts @@ -0,0 +1,17 @@ +type TextPartLike = { type: string; text: string }; +type CopyablePart = TextPartLike | { type: string }; + +type CopyableMessage = { + parts: readonly CopyablePart[]; +}; + +function isTextPartLike(part: CopyablePart): part is TextPartLike { + return part.type === 'text' && typeof (part as TextPartLike).text === 'string'; +} + +export function collectCopyableText(message: CopyableMessage): string { + return message.parts + .filter(isTextPartLike) + .map(part => part.text) + .join('\n\n'); +} diff --git a/apps/mobile/src/components/agents/message-bubble.tsx b/apps/mobile/src/components/agents/message-bubble.tsx index c60fb09a49..1581865989 100644 --- a/apps/mobile/src/components/agents/message-bubble.tsx +++ b/apps/mobile/src/components/agents/message-bubble.tsx @@ -1,8 +1,5 @@ import { type StoredMessage } from 'cloud-agent-sdk'; -import * as Clipboard from 'expo-clipboard'; -import { useCallback } from 'react'; -import { ActionSheetIOS, Platform, Pressable, View } from 'react-native'; -import { toast } from 'sonner-native'; +import { Pressable, View } from 'react-native'; import { Bubble } from '@/components/ui/bubble'; @@ -11,12 +8,14 @@ import { FilePartRenderer } from './file-part-renderer'; import { MarkdownText } from './markdown-text'; import { PartRenderer } from './part-renderer'; import { isFilePart, isTextPart } from './part-types'; +import { useMessageCopy } from './use-message-copy'; type MessageBubbleProps = { message: StoredMessage; isLastAssistantMessage?: boolean; isSessionStreaming?: boolean; getChildMessages?: (sessionId: string) => StoredMessage[]; + defaultReasoningExpanded?: boolean; }; export function MessageBubble({ @@ -24,33 +23,14 @@ export function MessageBubble({ isLastAssistantMessage, isSessionStreaming, getChildMessages, + defaultReasoningExpanded, }: Readonly) { const isUser = message.info.role === 'user'; + const { copyMessage } = useMessageCopy(); - const handleLongPress = useCallback(() => { - const textContent = message.parts - .filter(isTextPart) - .map(p => p.text) - .join('\n'); - if (!textContent) { - return; - } - - if (Platform.OS === 'ios') { - ActionSheetIOS.showActionSheetWithOptions( - { options: ['Copy Text', 'Cancel'], cancelButtonIndex: 1 }, - buttonIndex => { - if (buttonIndex === 0) { - void Clipboard.setStringAsync(textContent); - toast.success('Copied to clipboard'); - } - } - ); - } else { - void Clipboard.setStringAsync(textContent); - toast.success('Copied to clipboard'); - } - }, [message.parts]); + const handleLongPress = () => { + void copyMessage(message); + }; // Compaction-only message renders as a separator const firstPart = message.parts[0]; @@ -70,14 +50,20 @@ export function MessageBubble({ const fileParts = message.parts.filter(isFilePart); return ( - + {textContent ? : null} {fileParts.map(part => ( ))} - + ); } @@ -90,7 +76,7 @@ export function MessageBubble({ onLongPress={handleLongPress} accessibilityRole="text" accessibilityLabel="Assistant message" - accessibilityHint="Long press to copy text" + accessibilityHint="Long press to copy message text" > {message.parts.map(part => ( @@ -99,6 +85,7 @@ export function MessageBubble({ part={part} isStreaming={isStreaming} getChildMessages={getChildMessages} + defaultReasoningExpanded={defaultReasoningExpanded} /> ))} diff --git a/apps/mobile/src/components/agents/message-copy-text.test.ts b/apps/mobile/src/components/agents/message-copy-text.test.ts new file mode 100644 index 0000000000..2912f348ec --- /dev/null +++ b/apps/mobile/src/components/agents/message-copy-text.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { collectCopyableText } from './collect-copyable-text'; + +type TestMessage = { + parts: { type: string; text?: string; url?: string }[]; +}; + +describe('collectCopyableText', () => { + it('joins text parts and ignores non-text parts', () => { + const message: TestMessage = { + parts: [ + { type: 'text', text: 'Hello' }, + { type: 'file', url: 'x' }, + { type: 'text', text: 'world' }, + ], + }; + expect(collectCopyableText(message)).toBe('Hello\n\nworld'); + }); + + it('returns empty string when no text parts', () => { + const message: TestMessage = { + parts: [{ type: 'file', url: 'x' }], + }; + expect(collectCopyableText(message)).toBe(''); + }); +}); diff --git a/apps/mobile/src/components/agents/mobile-session-manager.ts b/apps/mobile/src/components/agents/mobile-session-manager.ts index 26fc3d0230..ae7d987ac7 100644 --- a/apps/mobile/src/components/agents/mobile-session-manager.ts +++ b/apps/mobile/src/components/agents/mobile-session-manager.ts @@ -142,6 +142,7 @@ export function createMobileAgentSessionManager({ payload, autoCommit: true, messageId: input.messageId, + ...(input.attachments ? { attachments: input.attachments } : {}), }; if (organizationId) { await trpcClient.organizations.cloudAgentNext.sendMessage.mutate( diff --git a/apps/mobile/src/components/agents/part-renderer.tsx b/apps/mobile/src/components/agents/part-renderer.tsx index 2d0d9e6fa8..996faa2f7f 100644 --- a/apps/mobile/src/components/agents/part-renderer.tsx +++ b/apps/mobile/src/components/agents/part-renderer.tsx @@ -19,9 +19,15 @@ type PartRendererProps = { part: Part; isStreaming?: boolean; getChildMessages?: (sessionId: string) => StoredMessage[]; + defaultReasoningExpanded?: boolean; }; -export function PartRenderer({ part, isStreaming, getChildMessages }: Readonly) { +export function PartRenderer({ + part, + isStreaming, + getChildMessages, + defaultReasoningExpanded, +}: Readonly) { if (isTextPart(part)) { return ( @@ -46,6 +52,7 @@ export function PartRenderer({ part, isStreaming, getChildMessages }: Readonly

); diff --git a/apps/mobile/src/components/agents/reasoning-part-renderer.tsx b/apps/mobile/src/components/agents/reasoning-part-renderer.tsx index 381c98f749..f7d1a447a4 100644 --- a/apps/mobile/src/components/agents/reasoning-part-renderer.tsx +++ b/apps/mobile/src/components/agents/reasoning-part-renderer.tsx @@ -10,10 +10,15 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; type ReasoningPartRendererProps = { text: string; isStreaming?: boolean; + defaultExpanded?: boolean; }; -export function ReasoningPartRenderer({ text, isStreaming }: Readonly) { - const [isExpanded, setIsExpanded] = useState(false); +export function ReasoningPartRenderer({ + text, + isStreaming, + defaultExpanded = false, +}: Readonly) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); const colors = useThemeColors(); return ( diff --git a/apps/mobile/src/components/agents/reasoning-settings-modal.tsx b/apps/mobile/src/components/agents/reasoning-settings-modal.tsx new file mode 100644 index 0000000000..151e8c542c --- /dev/null +++ b/apps/mobile/src/components/agents/reasoning-settings-modal.tsx @@ -0,0 +1,70 @@ +import { Modal, Platform, Pressable, Switch, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { useReasoningPreference } from '@/lib/hooks/use-reasoning-preference'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +type ReasoningSettingsModalProps = { + visible: boolean; + onClose: () => void; +}; + +export function ReasoningSettingsModal({ + visible, + onClose, +}: Readonly) { + const colors = useThemeColors(); + const { defaultExpanded, hasLoaded, setDefaultExpanded } = useReasoningPreference(); + + return ( + + + + { + event.stopPropagation(); + }} + > + Reasoning + { + if (!hasLoaded) { + return; + } + setDefaultExpanded(!defaultExpanded); + }} + disabled={!hasLoaded} + className="flex-row items-center justify-between gap-3 rounded-lg p-2 active:opacity-70 disabled:opacity-50" + accessibilityRole="switch" + accessibilityState={{ checked: defaultExpanded, disabled: !hasLoaded }} + accessibilityLabel="Expand reasoning by default" + hitSlop={ + Platform.OS === 'android' ? { top: 12, bottom: 12, left: 12, right: 12 } : undefined + } + > + + + Expand reasoning by default + + + Show the assistant's reasoning expanded when it finishes. + + + + + + + + ); +} diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 0bedbd9e9c..d8f6803a08 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -26,24 +26,20 @@ import { useSessionConfigSync } from '@/components/agents/use-session-config-syn import { WorkingIndicator } from '@/components/agents/working-indicator'; import { ScreenHeader } from '@/components/screen-header'; import { Text } from '@/components/ui/text'; +import { type AgentAttachmentWire } from '@/lib/agent-attachments/use-agent-attachment-upload'; import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle'; import { useAvailableModels } from '@/lib/hooks/use-available-models'; +import { usePersistedAgentModel } from '@/lib/hooks/use-persisted-agent-model'; +import { useReasoningPreference } from '@/lib/hooks/use-reasoning-preference'; type SessionDetailContentProps = { sessionId: KiloSessionId; }; -function getComposerPlaceholder(cloudStatusType: CloudStatus['type'] | undefined) { - if (cloudStatusType === 'preparing') { - return 'Setting up environment...'; - } - - if (cloudStatusType === 'finalizing') { - return 'Wrapping up...'; - } - - return 'Message...'; -} +const COMPOSER_PLACEHOLDERS: Partial> = { + preparing: 'Setting up environment...', + finalizing: 'Wrapping up...', +}; export function SessionDetailContent({ sessionId }: Readonly) { const manager = useSessionManager(); @@ -58,6 +54,7 @@ export function SessionDetailContent({ sessionId }: Readonly @@ -141,9 +140,10 @@ export function SessionDetailContent({ sessionId }: Readonly ), - [lastAssistantIndex, isStreaming, getChildMessages] + [lastAssistantIndex, isStreaming, getChildMessages, reasoningDefaultExpanded] ); const handleStop = useCallback(async () => { @@ -182,11 +182,12 @@ export function SessionDetailContent({ sessionId }: Readonly { + async (text: string, attachments?: AgentAttachmentWire) => { if (requiresModel && !currentModel) { toast.error('Select a model before sending'); return; @@ -201,6 +202,7 @@ export function SessionDetailContent({ sessionId }: Readonly { setCurrentModel(modelId); setCurrentVariant(newVariant); + // The remote-session CLI pseudo-model is not a real model; + // persisting it would clobber the real preference. + if (modelId !== CLI_MODEL_ID) { + savePersistedModel(organizationId, { model: modelId, variant: newVariant }); + } }} + organizationId={organizationId} + attachmentsEnabled={supportsAttachments} /> ))} diff --git a/apps/mobile/src/components/agents/use-message-copy.ts b/apps/mobile/src/components/agents/use-message-copy.ts new file mode 100644 index 0000000000..f258ef59aa --- /dev/null +++ b/apps/mobile/src/components/agents/use-message-copy.ts @@ -0,0 +1,43 @@ +import { type StoredMessage } from 'cloud-agent-sdk'; +import * as Clipboard from 'expo-clipboard'; +import * as Haptics from 'expo-haptics'; +import { useCallback } from 'react'; +import { ActionSheetIOS, Platform } from 'react-native'; +import { toast } from 'sonner-native'; + +import { collectCopyableText } from './collect-copyable-text'; + +export function useMessageCopy() { + const copyMessage = useCallback(async (message: StoredMessage) => { + const text = collectCopyableText(message); + if (!text) { + return; + } + + if (Platform.OS === 'ios') { + ActionSheetIOS.showActionSheetWithOptions( + { options: ['Copy Text', 'Cancel'], cancelButtonIndex: 1 }, + buttonIndex => { + if (buttonIndex === 0) { + void performCopy(text); + } + } + ); + return; + } + + await performCopy(text); + }, []); + + return { copyMessage }; +} + +async function performCopy(text: string) { + try { + await Clipboard.setStringAsync(text); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + toast.success('Copied to clipboard'); + } catch { + toast.error('Could not copy to clipboard'); + } +} diff --git a/apps/mobile/src/lib/agent-attachments/constants.ts b/apps/mobile/src/lib/agent-attachments/constants.ts new file mode 100644 index 0000000000..852dc29e9d --- /dev/null +++ b/apps/mobile/src/lib/agent-attachments/constants.ts @@ -0,0 +1,28 @@ +export const AGENT_ATTACHMENT_MAX_FILES = 5; +export const AGENT_ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024; + +export const AGENT_ATTACHMENT_EXTENSIONS = [ + 'png', + 'jpg', + 'jpeg', + 'webp', + 'gif', + 'pdf', + 'txt', + 'md', + 'csv', +] as const; + +export type AgentAttachmentExtension = (typeof AGENT_ATTACHMENT_EXTENSIONS)[number]; + +export const AGENT_ATTACHMENT_MIME_BY_EXTENSION = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + webp: 'image/webp', + gif: 'image/gif', + pdf: 'application/pdf', + txt: 'text/plain', + md: 'text/markdown', + csv: 'text/csv', +} as const satisfies Record; diff --git a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts new file mode 100644 index 0000000000..f3d6abd477 --- /dev/null +++ b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts @@ -0,0 +1,246 @@ +import * as Crypto from 'expo-crypto'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner-native'; + +import { trpcClient } from '@/lib/trpc'; +import { + AGENT_ATTACHMENT_MAX_BYTES, + AGENT_ATTACHMENT_MAX_FILES, + AGENT_ATTACHMENT_MIME_BY_EXTENSION, + type AgentAttachmentExtension, +} from '@/lib/agent-attachments/constants'; +import { canAddAttachments, classifyAttachment } from '@/lib/agent-attachments/validate'; + +export type AgentAttachmentKind = 'image' | 'document'; +export type AgentAttachmentStatus = 'pending' | 'uploading' | 'uploaded' | 'error'; + +type AllowedContentType = (typeof AGENT_ATTACHMENT_MIME_BY_EXTENSION)[AgentAttachmentExtension]; + +export type AgentAttachment = { + id: string; + filename: string; + /** Basename of the server-side R2 key; set once the upload succeeds. */ + remoteFilename?: string; + kind: AgentAttachmentKind; + mimeType: AllowedContentType; + size: number; + localUri: string; + status: AgentAttachmentStatus; + error?: string; +}; + +export type AgentAttachmentCandidate = { + name: string; + uri: string; + mimeType?: string; + size?: number; +}; + +export type AgentAttachmentWire = { + path: string; + files: string[]; +}; + +type UseAgentAttachmentUploadOptions = { + organizationId?: string; +}; + +type UseAgentAttachmentUploadReturn = { + attachments: AgentAttachment[]; + addCandidates: (candidates: AgentAttachmentCandidate[]) => void; + removeAttachment: (id: string) => void; + reset: () => void; + isUploading: boolean; + toWirePayload: () => AgentAttachmentWire | undefined; +}; + +function ensureExtension(name: string, fallback: string): string { + const dot = name.lastIndexOf('.'); + if (dot > 0 && dot < name.length - 1) { + return name; + } + return `${name}.${fallback}`; +} + +async function uploadOne(args: { + organizationId?: string; + attachmentId: string; + path: string; + contentType: AllowedContentType; + localUri: string; +}): Promise<{ key: string }> { + const { organizationId, attachmentId, path, contentType, localUri } = args; + // expo-file-system's `File` is not a `Blob`; materialize a real `Blob` from + // the file:// URI so the PUT body matches the signed Content-Length. + const localFileResponse = await fetch(localUri); + const blob = await localFileResponse.blob(); + if (blob.size > AGENT_ATTACHMENT_MAX_BYTES) { + throw new Error('File is larger than 5 MB'); + } + const baseInput = { + messageUuid: path, + attachmentId, + contentType, + contentLength: blob.size, + }; + const result = organizationId + ? await trpcClient.organizations.cloudAgentNext.getAttachmentUploadUrl.mutate({ + ...baseInput, + organizationId, + }) + : await trpcClient.cloudAgentNext.getAttachmentUploadUrl.mutate(baseInput); + const response = await fetch(result.signedUrl, { + method: 'PUT', + headers: { 'Content-Type': contentType }, + body: blob, + }); + if (!response.ok) { + throw new Error(`Upload failed with status ${response.status}`); + } + return { key: result.key }; +} + +export function useAgentAttachmentUpload( + options: UseAgentAttachmentUploadOptions = {} +): UseAgentAttachmentUploadReturn { + const { organizationId } = options; + const [attachments, setAttachments] = useState([]); + const pathRef = useRef(Crypto.randomUUID()); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const startUpload = useCallback( + (attachment: AgentAttachment, path: string) => { + const update = (patch: Partial) => { + if (!isMountedRef.current) { + return; + } + setAttachments(current => + current.map(item => (item.id === attachment.id ? { ...item, ...patch } : item)) + ); + }; + + const run = async () => { + update({ status: 'uploading' }); + try { + const { key } = await uploadOne({ + organizationId, + attachmentId: attachment.id, + path, + contentType: attachment.mimeType, + localUri: attachment.localUri, + }); + // The wire payload must reference the object the server actually + // stored, so take the filename from the returned R2 key. + update({ status: 'uploaded', remoteFilename: key.split('/').at(-1) }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Upload failed'; + toast.error(`Failed to upload file: ${message}`); + update({ status: 'error', error: message }); + } + }; + + void run(); + }, + [organizationId] + ); + + const addCandidates = useCallback( + (candidates: AgentAttachmentCandidate[]) => { + if (candidates.length === 0) { + return; + } + const limit = canAddAttachments(attachments.length, candidates.length); + if (!limit.ok) { + toast.error(`Maximum ${AGENT_ATTACHMENT_MAX_FILES} files allowed`); + return; + } + const accepted = candidates.slice(0, limit.acceptedCount); + if (limit.truncated) { + toast.warning( + `Only adding ${limit.acceptedCount} of ${candidates.length} files (max ${AGENT_ATTACHMENT_MAX_FILES})` + ); + } + + const additions: AgentAttachment[] = []; + for (const candidate of accepted) { + const classified = classifyAttachment({ + name: candidate.name, + mimeType: candidate.mimeType, + size: candidate.size, + }); + if (!classified.ok) { + toast.error( + classified.reason === 'too-large' + ? `File too large: ${candidate.name}. Max size is 5 MB.` + : `File type not supported: ${candidate.name}. Attach PNG, JPEG, WebP, GIF, PDF, TXT, MD, or CSV files.` + ); + } else { + const ext = classified.extension; + additions.push({ + id: Crypto.randomUUID(), + filename: ensureExtension(candidate.name, ext), + kind: classified.kind, + // Always derive the content type from the extension: OS pickers + // report generic types (e.g. application/octet-stream) that the + // backend's allowed-type enum rejects. + mimeType: AGENT_ATTACHMENT_MIME_BY_EXTENSION[ext], + size: candidate.size ?? 0, + localUri: candidate.uri, + status: 'pending', + }); + } + } + if (additions.length === 0) { + return; + } + for (const addition of additions) { + startUpload(addition, pathRef.current); + } + setAttachments(current => [...current, ...additions]); + }, + [attachments.length, startUpload] + ); + + const removeAttachment = useCallback((id: string) => { + setAttachments(current => current.filter(item => item.id !== id)); + }, []); + + const reset = useCallback(() => { + setAttachments([]); + pathRef.current = Crypto.randomUUID(); + }, []); + + const toWirePayload = useCallback((): AgentAttachmentWire | undefined => { + const files = attachments + .filter(item => item.status === 'uploaded') + .map(item => item.remoteFilename) + .filter((filename): filename is string => filename !== undefined); + if (files.length === 0) { + return undefined; + } + return { path: pathRef.current, files }; + }, [attachments]); + + const isUploading = attachments.some( + item => item.status === 'pending' || item.status === 'uploading' + ); + + return useMemo( + () => ({ + attachments, + addCandidates, + removeAttachment, + reset, + isUploading, + toWirePayload, + }), + [attachments, addCandidates, removeAttachment, reset, isUploading, toWirePayload] + ); +} diff --git a/apps/mobile/src/lib/agent-attachments/validate.test.ts b/apps/mobile/src/lib/agent-attachments/validate.test.ts new file mode 100644 index 0000000000..548d79f3f3 --- /dev/null +++ b/apps/mobile/src/lib/agent-attachments/validate.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { canAddAttachments, classifyAttachment } from './validate'; + +describe('classifyAttachment', () => { + it('accepts a png by extension', () => { + expect(classifyAttachment({ name: 'a.PNG', mimeType: 'image/png', size: 10 })).toEqual({ + ok: true, + kind: 'image', + extension: 'png', + }); + }); + + it('accepts a markdown file by extension', () => { + expect(classifyAttachment({ name: 'notes.md', mimeType: 'text/markdown', size: 10 })).toEqual({ + ok: true, + kind: 'document', + extension: 'md', + }); + }); + + it('rejects unsupported extension', () => { + expect( + classifyAttachment({ name: 'a.exe', mimeType: 'application/octet-stream', size: 10 }).ok + ).toBe(false); + }); + + it('rejects a file over the size cap', () => { + expect( + classifyAttachment({ name: 'a.pdf', mimeType: 'application/pdf', size: 6 * 1024 * 1024 }).ok + ).toBe(false); + }); +}); + +describe('canAddAttachments', () => { + it('allows up to 5 total', () => { + expect(canAddAttachments(3, 2)).toEqual({ ok: true, acceptedCount: 2 }); + }); + + it('truncates past 5 and reports partial', () => { + expect(canAddAttachments(4, 3)).toEqual({ ok: true, acceptedCount: 1, truncated: true }); + }); + + it('rejects when already full', () => { + expect(canAddAttachments(5, 1)).toEqual({ ok: false, acceptedCount: 0 }); + }); +}); diff --git a/apps/mobile/src/lib/agent-attachments/validate.ts b/apps/mobile/src/lib/agent-attachments/validate.ts new file mode 100644 index 0000000000..ce09c9d77f --- /dev/null +++ b/apps/mobile/src/lib/agent-attachments/validate.ts @@ -0,0 +1,43 @@ +import { + AGENT_ATTACHMENT_EXTENSIONS, + AGENT_ATTACHMENT_MAX_BYTES, + AGENT_ATTACHMENT_MAX_FILES, + type AgentAttachmentExtension, +} from './constants'; + +const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif']); + +type ClassifiedAttachment = + | { ok: true; kind: 'image' | 'document'; extension: AgentAttachmentExtension } + | { ok: false; reason: 'unsupported' | 'too-large' }; + +type Candidate = { name: string; mimeType?: string; size?: number }; + +export function classifyAttachment(candidate: Candidate): ClassifiedAttachment { + const ext = candidate.name.split('.').pop()?.toLowerCase(); + if (!ext || !(AGENT_ATTACHMENT_EXTENSIONS as readonly string[]).includes(ext)) { + return { ok: false, reason: 'unsupported' }; + } + if (typeof candidate.size === 'number' && candidate.size > AGENT_ATTACHMENT_MAX_BYTES) { + return { ok: false, reason: 'too-large' }; + } + return { + ok: true, + kind: IMAGE_EXTENSIONS.has(ext) ? 'image' : 'document', + extension: ext as AgentAttachmentExtension, + }; +} + +export function canAddAttachments( + currentCount: number, + incomingCount: number +): { ok: boolean; acceptedCount: number; truncated?: boolean } { + const remaining = AGENT_ATTACHMENT_MAX_FILES - currentCount; + if (remaining <= 0) { + return { ok: false, acceptedCount: 0 }; + } + if (incomingCount <= remaining) { + return { ok: true, acceptedCount: incomingCount }; + } + return { ok: true, acceptedCount: remaining, truncated: true }; +} diff --git a/apps/mobile/src/lib/auth/auth-context.tsx b/apps/mobile/src/lib/auth/auth-context.tsx index 1c9adef189..aafa3d40f8 100644 --- a/apps/mobile/src/lib/auth/auth-context.tsx +++ b/apps/mobile/src/lib/auth/auth-context.tsx @@ -12,6 +12,8 @@ import { import { trackEvent } from '@/lib/appsflyer'; import { queryClient } from '@/lib/query-client'; import { setTrpcUnauthorizedHandler } from '@/lib/auth/trpc-unauthorized'; +import { clearAgentModelPreference } from '@/lib/hooks/use-persisted-agent-model'; +import { clearReasoningPreference } from '@/lib/hooks/use-reasoning-preference'; import { resetPurchaseErrorToastDedup } from '@/lib/kilo-pass/use-store-kilo-pass-purchase'; import { AUTH_TOKEN_KEY, @@ -61,6 +63,8 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) { await SecureStore.deleteItemAsync(ORGANIZATION_STORAGE_KEY); await SecureStore.deleteItemAsync(SESSION_FILTERS_KEY); await SecureStore.deleteItemAsync(NOTIFICATION_PROMPT_SEEN_KEY); + clearAgentModelPreference(); + clearReasoningPreference(); queryClient.clear(); setToken(undefined); }, []); diff --git a/apps/mobile/src/lib/hooks/agent-model-preference.ts b/apps/mobile/src/lib/hooks/agent-model-preference.ts new file mode 100644 index 0000000000..362b638579 --- /dev/null +++ b/apps/mobile/src/lib/hooks/agent-model-preference.ts @@ -0,0 +1,56 @@ +import { type ModelOption } from '@/lib/hooks/use-available-models'; + +export type ModelPreferenceEntry = { model: string; variant: string }; +export type StoredModelPreference = Record; + +export function contextKey(organizationId?: string): string { + return organizationId ?? 'personal'; +} + +export function parseStoredModelPreference(raw: string | null): StoredModelPreference { + if (!raw) { + return {}; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null) { + return {}; + } + const result: StoredModelPreference = {}; + for (const [key, value] of Object.entries(parsed)) { + if ( + typeof value === 'object' && + value !== null && + typeof (value as ModelPreferenceEntry).model === 'string' && + typeof (value as ModelPreferenceEntry).variant === 'string' + ) { + result[key] = { + model: (value as ModelPreferenceEntry).model, + variant: (value as ModelPreferenceEntry).variant, + }; + } + } + return result; + } catch { + return {}; + } +} + +export function resolveModelForContext( + stored: StoredModelPreference, + context: string, + options: ModelOption[] +): ModelPreferenceEntry | undefined { + const entry = stored[context]; + if (!entry) { + return undefined; + } + const match = options.find(o => o.id === entry.model); + if (!match) { + return undefined; + } + if (entry.variant && !match.variants.includes(entry.variant)) { + return { model: match.id, variant: match.variants[0] ?? '' }; + } + return { model: entry.model, variant: entry.variant }; +} diff --git a/apps/mobile/src/lib/hooks/secure-store-preference.ts b/apps/mobile/src/lib/hooks/secure-store-preference.ts new file mode 100644 index 0000000000..6034473689 --- /dev/null +++ b/apps/mobile/src/lib/hooks/secure-store-preference.ts @@ -0,0 +1,86 @@ +import * as SecureStore from 'expo-secure-store'; + +/** + * Module-level store for a SecureStore-backed preference so every hook + * instance (settings sheet, message list, new-session screen) shares one + * value and one disk read. Consume via useSyncExternalStore. + */ +export function createSecureStorePreference(options: { + key: string; + defaultValue: T; + parse: (raw: string | null) => T; + serialize: (value: T) => string; +}) { + const { key, defaultValue, parse, serialize } = options; + let value = defaultValue; + let hasLoaded = false; + // A set() before the initial load resolves must win over the disk value. + let dirty = false; + let loadStarted = false; + const listeners = new Set<() => void>(); + + const emit = () => { + for (const listener of listeners) { + listener(); + } + }; + + const load = async () => { + try { + const raw = await SecureStore.getItemAsync(key); + if (!dirty) { + value = parse(raw); + } + } catch { + // Keep the default on read failure. + } finally { + hasLoaded = true; + emit(); + } + }; + + const persist = async (next: T) => { + try { + await SecureStore.setItemAsync(key, serialize(next)); + } catch { + // Keep the in-memory preference even if the storage write fails. + } + }; + + const remove = async () => { + try { + await SecureStore.deleteItemAsync(key); + } catch { + // Best effort; the in-memory value is already reset. + } + }; + + return { + subscribe: (listener: () => void) => { + if (!loadStarted) { + loadStarted = true; + void load(); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + get: () => value, + getHasLoaded: () => hasLoaded, + set: (next: T) => { + value = next; + dirty = true; + hasLoaded = true; + emit(); + void persist(next); + }, + /** Reset memory and disk (e.g. on sign-out). */ + clear: () => { + value = defaultValue; + dirty = false; + emit(); + void remove(); + }, + }; +} diff --git a/apps/mobile/src/lib/hooks/use-persisted-agent-model.test.ts b/apps/mobile/src/lib/hooks/use-persisted-agent-model.test.ts new file mode 100644 index 0000000000..b13d74cd62 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-persisted-agent-model.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { + parseStoredModelPreference, + resolveModelForContext, + type StoredModelPreference, +} from './agent-model-preference'; + +const options = [ + { id: 'anthropic/claude', name: 'Claude', variants: ['thinking'], isPreferred: true }, + { id: 'openai/gpt', name: 'GPT', variants: [], isPreferred: false }, +]; + +describe('parseStoredModelPreference', () => { + it('returns empty map for invalid json', () => { + expect(parseStoredModelPreference('not json')).toEqual({}); + }); + + it('parses a valid record keyed by context', () => { + const raw = JSON.stringify({ personal: { model: 'openai/gpt', variant: '' } }); + expect(parseStoredModelPreference(raw)).toEqual({ + personal: { model: 'openai/gpt', variant: '' }, + }); + }); + + it('drops entries with non-string model', () => { + const raw = JSON.stringify({ personal: { model: 42, variant: '' } }); + expect(parseStoredModelPreference(raw)).toEqual({}); + }); +}); + +describe('resolveModelForContext', () => { + const stored: StoredModelPreference = { + personal: { model: 'openai/gpt', variant: '' }, + org_123: { model: 'deleted/model', variant: 'thinking' }, + }; + + it('returns persisted model when it exists in options', () => { + expect(resolveModelForContext(stored, 'personal', options)).toEqual({ + model: 'openai/gpt', + variant: '', + }); + }); + + it('returns undefined when persisted model is not in options', () => { + expect(resolveModelForContext(stored, 'org_123', options)).toBeUndefined(); + }); + + it('resets variant when persisted variant no longer exists', () => { + const s: StoredModelPreference = { personal: { model: 'anthropic/claude', variant: 'gone' } }; + expect(resolveModelForContext(s, 'personal', options)).toEqual({ + model: 'anthropic/claude', + variant: 'thinking', + }); + }); + + it('returns undefined for unknown context', () => { + expect(resolveModelForContext(stored, 'org_999', options)).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-persisted-agent-model.ts b/apps/mobile/src/lib/hooks/use-persisted-agent-model.ts new file mode 100644 index 0000000000..3940db50a8 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-persisted-agent-model.ts @@ -0,0 +1,31 @@ +import { useSyncExternalStore } from 'react'; + +import { + contextKey, + type ModelPreferenceEntry, + parseStoredModelPreference, + type StoredModelPreference, +} from '@/lib/hooks/agent-model-preference'; +import { createSecureStorePreference } from '@/lib/hooks/secure-store-preference'; +import { AGENT_MODEL_PREFERENCE_KEY } from '@/lib/storage-keys'; + +const store = createSecureStorePreference({ + key: AGENT_MODEL_PREFERENCE_KEY, + defaultValue: {}, + parse: parseStoredModelPreference, + serialize: value => JSON.stringify(value), +}); + +export function clearAgentModelPreference() { + store.clear(); +} + +function saveModel(organizationId: string | undefined, entry: ModelPreferenceEntry) { + store.set({ ...store.get(), [contextKey(organizationId)]: entry }); +} + +export function usePersistedAgentModel() { + const stored = useSyncExternalStore(store.subscribe, store.get); + const hasLoaded = useSyncExternalStore(store.subscribe, store.getHasLoaded); + return { stored, hasLoaded, saveModel }; +} diff --git a/apps/mobile/src/lib/hooks/use-reasoning-preference.ts b/apps/mobile/src/lib/hooks/use-reasoning-preference.ts new file mode 100644 index 0000000000..084dd45f09 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-reasoning-preference.ts @@ -0,0 +1,25 @@ +import { useSyncExternalStore } from 'react'; + +import { createSecureStorePreference } from '@/lib/hooks/secure-store-preference'; +import { REASONING_DEFAULT_EXPANDED_KEY } from '@/lib/storage-keys'; + +const store = createSecureStorePreference({ + key: REASONING_DEFAULT_EXPANDED_KEY, + defaultValue: false, + parse: raw => raw === 'true', + serialize: value => (value ? 'true' : 'false'), +}); + +export function clearReasoningPreference() { + store.clear(); +} + +function setDefaultExpanded(value: boolean) { + store.set(value); +} + +export function useReasoningPreference() { + const defaultExpanded = useSyncExternalStore(store.subscribe, store.get); + const hasLoaded = useSyncExternalStore(store.subscribe, store.getHasLoaded); + return { defaultExpanded, hasLoaded, setDefaultExpanded }; +} diff --git a/apps/mobile/src/lib/storage-keys.ts b/apps/mobile/src/lib/storage-keys.ts index 2e4a3c8928..541feac18f 100644 --- a/apps/mobile/src/lib/storage-keys.ts +++ b/apps/mobile/src/lib/storage-keys.ts @@ -12,3 +12,5 @@ export const SESSION_FILTERS_KEY = 'agent-session-filters'; export const NOTIFICATION_PROMPT_SEEN_KEY = 'notification-prompt-seen'; export const LAST_ACTIVE_INSTANCE_KEY = 'last-active-chat-instance'; export const CONSENT_USER_KEY_PREFIX = 'consent-accepted-'; +export const AGENT_MODEL_PREFERENCE_KEY = 'agent-model-preference'; +export const REASONING_DEFAULT_EXPANDED_KEY = 'agent-reasoning-default-expanded'; diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index 331062a1e0..d0c20df73e 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -22,8 +22,10 @@ export default defineConfig({ environment: 'node', include: [ 'src/lib/*.test.ts', + 'src/lib/agent-attachments/**/*.test.ts', 'src/lib/apple-iap/**/*.test.ts', 'src/lib/apple-iap/**/*.test.tsx', + 'src/lib/hooks/**/*.test.ts', 'src/lib/kilo-pass/**/*.test.ts', 'src/lib/kilo-pass/**/*.test.tsx', 'src/lib/onboarding/**/*.test.ts',