diff --git a/apps/mobile/src/MobileAiPanel.tsx b/apps/mobile/src/MobileAiPanel.tsx index 7385b964..787716e6 100644 --- a/apps/mobile/src/MobileAiPanel.tsx +++ b/apps/mobile/src/MobileAiPanel.tsx @@ -1,31 +1,35 @@ -import { CaretLeft, PaperPlaneTilt } from 'phosphor-react-native' +import { CaretLeft, GearSix, PaperPlaneTilt, Robot } from 'phosphor-react-native' import { useState } from 'react' import { Pressable, ScrollView, Text, TextInput, View } from 'react-native' import type { MobileNote } from './mobileNoteProjection' -import { sendMobileAiRequest } from './mobileAiClient' +import type { MobileAiProvider } from './mobileAiSettings' import { styles } from './styles' import { colors } from './theme' export function MobileAiPanel({ note, onClose, + onOpenSettings, + onSendPrompt, + provider, }: { note: MobileNote onClose?: () => void + onOpenSettings: () => void + onSendPrompt: (prompt: string, provider: MobileAiProvider) => Promise + provider: MobileAiProvider | null }) { - const [apiKey, setApiKey] = useState('') - const [baseUrl, setBaseUrl] = useState('https://api.openai.com/v1') const [failed, setFailed] = useState(false) const [isSending, setIsSending] = useState(false) - const [model, setModel] = useState('') const [prompt, setPrompt] = useState('') const [response, setResponse] = useState('') - const canSend = apiKey.trim().length > 0 && baseUrl.trim().length > 0 && model.trim().length > 0 && prompt.trim().length > 0 const sendPrompt = () => { + if (!provider || prompt.trim().length === 0) return + setFailed(false) setIsSending(true) - void sendMobileAiRequest({ apiKey, baseUrl, model, note, prompt }) + void onSendPrompt(prompt, provider) .then(setResponse) .catch(() => setFailed(true)) .finally(() => setIsSending(false)) @@ -33,36 +37,39 @@ export function MobileAiPanel({ return ( - - - Model - - + {provider ? ( + - - + ) : ( + + )} ) } -function AiToolbar({ onClose }: { onClose?: () => void }) { +function AiToolbar({ + onClose, + onOpenSettings, +}: { + onClose?: () => void + onOpenSettings: () => void +}) { return ( AI + [styles.iconButton, pressed ? styles.pressed : null]}> + + {onClose ? ( [styles.iconButton, pressed ? styles.pressed : null]}> @@ -72,48 +79,49 @@ function AiToolbar({ onClose }: { onClose?: () => void }) { ) } -function AiModelFields({ - apiKey, - baseUrl, - model, - onChangeApiKey, - onChangeBaseUrl, - onChangeModel, -}: { - apiKey: string - baseUrl: string - model: string - onChangeApiKey: (value: string) => void - onChangeBaseUrl: (value: string) => void - onChangeModel: (value: string) => void -}) { +function AiEmptyState({ onOpenSettings }: { onOpenSettings: () => void }) { return ( - <> - - - - + + + No API model configured + Add an API model in Settings before using the AI panel. + [styles.aiSettingsButton, pressed ? styles.pressed : null]}> + + Open Settings + + ) } -function AiPromptComposer({ - canSend, +function AiChatSurface({ + failed, isSending, note, onChangePrompt, onSend, prompt, + provider, + response, }: { - canSend: boolean + failed: boolean isSending: boolean note: MobileNote onChangePrompt: (value: string) => void onSend: () => void prompt: string + provider: MobileAiProvider + response: string }) { + const canSend = prompt.trim().length > 0 && !isSending + return ( - <> - Prompt + + + {provider.name} ยท {provider.modelId} + {note.title} + + {response ? {response} : Ask about the active note. API models run in chat mode only.} + {failed ? AI request failed. : null} - - - ) -} - -function AiInput({ - onChangeText, - placeholder, - secureTextEntry = false, - value, -}: { - onChangeText: (value: string) => void - placeholder: string - secureTextEntry?: boolean - value: string -}) { - return ( - - ) -} - -function AiSendButton({ - canSend, - isSending, - onSend, -}: { - canSend: boolean - isSending: boolean - onSend: () => void -}) { - return ( - [ - styles.aiSendButton, - !canSend || isSending ? styles.composeButtonDisabled : null, - pressed ? styles.pressed : null, - ]} - > - - {isSending ? 'Sending' : 'Send'} - - ) -} - -function AiResult({ - failed, - response, -}: { - failed: boolean - response: string -}) { - return ( - <> - {failed ? AI request failed. : null} - {response ? {response} : null} - + [ + styles.aiSendButton, + !canSend ? styles.composeButtonDisabled : null, + pressed ? styles.pressed : null, + ]} + > + + {isSending ? 'Sending' : 'Send'} + + ) } diff --git a/apps/mobile/src/MobileAiSettingsPanel.tsx b/apps/mobile/src/MobileAiSettingsPanel.tsx new file mode 100644 index 00000000..bdbf53fb --- /dev/null +++ b/apps/mobile/src/MobileAiSettingsPanel.tsx @@ -0,0 +1,197 @@ +import { CaretLeft, Trash } from 'phosphor-react-native' +import { useState } from 'react' +import { Pressable, ScrollView, Text, TextInput, View } from 'react-native' +import { + mobileAiProviderPresets, + type MobileAiProvider, + type MobileAiProviderDraft, + type MobileAiProviderKind, + type MobileAiSettings, +} from './mobileAiSettings' +import { styles } from './styles' +import { colors } from './theme' + +const providerKinds: MobileAiProviderKind[] = ['open_ai', 'anthropic', 'gemini', 'open_router', 'open_ai_compatible'] + +export function MobileAiSettingsPanel({ + failed, + isSaving, + onAddProvider, + onClose, + onRemoveProvider, + settings, +}: { + failed: boolean + isSaving: boolean + onAddProvider: (draft: MobileAiProviderDraft) => Promise + onClose?: () => void + onRemoveProvider: (providerId: string) => Promise + settings: MobileAiSettings +}) { + return ( + + + + API models + API keys are saved locally on this device and are not written to vault settings. + + + + + ) +} + +function ProviderDraftForm({ + failed, + isSaving, + onAddProvider, +}: { + failed: boolean + isSaving: boolean + onAddProvider: (draft: MobileAiProviderDraft) => Promise +}) { + const [draft, setDraft] = useState(() => initialDraft('open_ai')) + const canSave = isProviderDraftReady(draft) + const isDisabled = !canSave || isSaving + const submitProvider = () => { + if (isDisabled) return + + void onAddProvider(draft).then((saved) => { + if (saved) { + setDraft(initialDraft(draft.kind)) + } + }) + } + + return ( + <> + setDraft(initialDraft(kind))} /> + setDraft((current) => ({ ...current, modelId }))} + placeholder={mobileAiProviderPresets[draft.kind].placeholder} + placeholderTextColor={colors.mutedText} + style={styles.aiInput} + value={draft.modelId} + /> + setDraft((current) => ({ ...current, apiKey }))} + placeholder="API key" + placeholderTextColor={colors.mutedText} + secureTextEntry + style={styles.aiInput} + value={draft.apiKey} + /> + + + + ) +} + +function ProviderSaveError({ failed }: { failed: boolean }) { + return failed ? Could not save AI settings. : null +} + +function ProviderSaveButton({ + disabled, + isSaving, + onPress, +}: { + disabled: boolean + isSaving: boolean + onPress: () => void +}) { + return ( + [ + styles.aiSettingsButton, + disabled ? styles.composeButtonDisabled : null, + pressed ? styles.pressed : null, + ]} + > + {isSaving ? 'Saving' : 'Add API model'} + + ) +} + +function isProviderDraftReady(draft: MobileAiProviderDraft) { + return draft.modelId.trim().length > 0 && draft.apiKey.trim().length > 0 +} + +function SettingsToolbar({ onClose }: { onClose?: () => void }) { + return ( + + Settings + + {onClose ? ( + [styles.iconButton, pressed ? styles.pressed : null]}> + + + ) : null} + + ) +} + +function ProviderList({ + onRemoveProvider, + providers, +}: { + onRemoveProvider: (providerId: string) => void + providers: MobileAiProvider[] +}) { + if (providers.length === 0) { + return No API models configured. + } + + return ( + + {providers.map((provider) => ( + + + {provider.name} + {provider.modelId} + + onRemoveProvider(provider.id)} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}> + + + + ))} + + ) +} + +function ProviderKindPicker({ + onChange, + value, +}: { + onChange: (value: MobileAiProviderKind) => void + value: MobileAiProviderKind +}) { + return ( + + {providerKinds.map((kind) => ( + onChange(kind)} + style={({ pressed }) => [ + styles.aiProviderKindChip, + value === kind ? styles.aiProviderKindChipSelected : null, + pressed ? styles.pressed : null, + ]} + > + {mobileAiProviderPresets[kind].name} + + ))} + + ) +} + +function initialDraft(kind: MobileAiProviderKind): MobileAiProviderDraft { + return { + apiKey: '', + kind, + modelId: '', + name: mobileAiProviderPresets[kind].name, + } +} diff --git a/apps/mobile/src/MobileApp.tsx b/apps/mobile/src/MobileApp.tsx index 7037b654..065f70e2 100644 --- a/apps/mobile/src/MobileApp.tsx +++ b/apps/mobile/src/MobileApp.tsx @@ -51,6 +51,7 @@ import { colors } from './theme' import { MobileEditorBreadcrumb } from './MobileEditorBreadcrumb' import { MobilePropertiesPanel } from './MobilePropertiesPanel' import { MobileAiPanel } from './MobileAiPanel' +import { MobileAiSettingsPanel } from './MobileAiSettingsPanel' import { MobileVaultManagementCard } from './MobileVaultManagementCard' import { MobileVaultRemotePrompt } from './MobileVaultRemotePrompt' import { useMobileNoteCreateFlow } from './useMobileNoteCreateFlow' @@ -58,6 +59,8 @@ import { useMobileNoteDeleteFlow } from './useMobileNoteDeleteFlow' import { useMobileNotePropertiesFlow } from './useMobileNotePropertiesFlow' import { useMobileVaultRemoteSetupFlow } from './useMobileVaultRemoteSetupFlow' import { createNativeMobileAppStateStorage } from './mobileNativeAppStateStorage' +import { createNativeMobileAiProviderSecretStorage } from './mobileNativeAiProviderSecretStorage' +import { createNativeMobileAiSettingsStorage } from './mobileNativeAiSettingsStorage' import { createNativeMobileVaultMetadataStorage } from './mobileNativeVaultMetadataStorage' import { createNativeMobileGitCredentialStorage } from './mobileNativeGitCredentialStorage' import { @@ -70,6 +73,7 @@ import type { MobileVaultRuntime } from './mobileVaultRuntime' import { useMobileVaultRuntimeLoader } from './useMobileVaultRuntimeLoader' import type { MobileNotePropertyPatch } from './mobileNoteProperties' import { useMobileGitSyncFlow } from './useMobileGitSyncFlow' +import { useMobileAiSettingsFlow } from './useMobileAiSettingsFlow' import { createNativeMobileGitTransport } from './mobileNativeGitTransport' import { loadExpoMobileGitNativeModule } from './mobileExpoNativeGitModule' import { applyMobileRawNoteContent } from './mobileRawNoteProjection' @@ -82,11 +86,14 @@ import { type MobileSidebarSelection, } from './mobileSidebarNavigation' import { mobileTypeAppearance } from './mobileTypeAppearance' +import type { MobileAiProvider, MobileAiProviderDraft, MobileAiSettings } from './mobileAiSettings' export function MobileApp() { const { width } = useWindowDimensions() const isTablet = width >= 820 const showsProperties = width >= 1000 + const aiProviderSecretStorage = useMemo(() => createNativeMobileAiProviderSecretStorage(), []) + const aiSettingsStorage = useMemo(() => createNativeMobileAiSettingsStorage(), []) const appStateStorage = useMemo(() => createNativeMobileAppStateStorage(), []) const gitCredentialStorage = useMemo(() => createNativeMobileGitCredentialStorage(), []) const gitTransport = useMemo(() => createNativeMobileGitTransport(loadExpoMobileGitNativeModule()), []) @@ -96,7 +103,7 @@ export function MobileApp() { const [availableNotes, setAvailableNotes] = useState(fallbackNotes) const [compactNavigation, setCompactNavigation] = useState(() => createCompactNavigationState(fallbackNotes[0].id)) const [editorModeByNoteId, setEditorModeByNoteId] = useState>({}) - const [rightPanel, setRightPanel] = useState<'ai' | 'properties'>('properties') + const [rightPanel, setRightPanel] = useState<'ai' | 'properties' | 'settings'>('properties') const [sidebarSelection, setSidebarSelection] = useState(defaultMobileSidebarSelection) const [saveStateByNoteId, setSaveStateByNoteId] = useState>({}) const sidebarSections = useMemo(() => createMobileSidebarSections(availableNotes), [availableNotes]) @@ -117,6 +124,10 @@ export function MobileApp() { gitTransport, vault: activeVaultMetadata, }) + const aiSettingsFlow = useMobileAiSettingsFlow({ + secretStorage: aiProviderSecretStorage, + settingsStorage: aiSettingsStorage, + }) const autosaveQueue = useMemo( () => createMobileAutosaveQueue({ @@ -227,108 +238,269 @@ export function MobileApp() { propertiesFlow.saveProperties({ favorite, favoriteIndex }) }, [availableNotes, propertiesFlow, selectedNote.favorite, selectedNote.id]) + const appShellProps: MobileAppShellProps = { + activeVault: activeVaultMetadata, + aiFailed: aiSettingsFlow.failed, + aiSettings: aiSettingsFlow.settings, + allNotes: availableNotes, + compactNavigation, + createNoteFailed: createFlow.failed, + editorMode: selectedEditorMode, + gitHubOAuthClientIdState: gitHubOAuthClientIdState.state, + gitSyncPlan: gitSyncFlow.gitSyncPlan, + isCreatingNote: createFlow.isCreating, + isSavingAiSettings: aiSettingsFlow.isSaving, + isSavingProperties: propertiesFlow.isSaving, + isTablet, + listTitle, + notes: visibleNotes, + propertiesFailed: propertiesFlow.failed, + rightPanel, + runtimeLoadFailed: runtimeLoader.failed, + saveState: selectedSaveState, + selectedNote, + selectedNoteId: compactNavigation.selectedNoteId, + selectedProvider: aiSettingsFlow.selectedProvider, + showsProperties, + sidebarSections, + sidebarSelection, + isRemoteSetupOpen: remoteSetupFlow.isOpen, + remoteSetupFailed: remoteSetupFlow.failed, + remoteSetupIsSaving: remoteSetupFlow.isSaving, + remoteSetupUrl: remoteSetupFlow.remoteUrl, + onAddAiProvider: aiSettingsFlow.addProvider, + onCancelRemoteSetup: remoteSetupFlow.cancel, + onChangeProperties: propertiesFlow.saveProperties, + onChangeRemoteUrl: remoteSetupFlow.setRemoteUrl, + onCreateNote: createFlow.create, + onDeleteNote: deleteFlow.canDelete ? deleteFlow.deleteSelectedNote : undefined, + onDraftChange: saveDraft, + onGitSyncAction: gitSyncFlow.runPrimaryAction, + onNavigate: (event) => setCompactNavigation((state) => transitionCompactNavigation(state, event)), + onOpenAi: () => setRightPanel('ai'), + onOpenAiSettings: () => setRightPanel('settings'), + onOpenProperties: () => setRightPanel('properties'), + onOpenRemoteSetup: remoteSetupFlow.open, + onRawMarkdownChange: saveRawMarkdown, + onRemoveAiProvider: aiSettingsFlow.removeProvider, + onRetryRuntimeLoad: runtimeLoader.retry, + onSelectNote: selectNote, + onSelectNoteId: selectNoteId, + onSelectSidebar: selectSidebar, + onSendAiPrompt: (prompt, provider) => aiSettingsFlow.sendPrompt({ note: selectedNote, prompt, provider }), + onSubmitRemoteSetup: remoteSetupFlow.submit, + onToggleArchive: toggleSelectedArchive, + onToggleEditorMode: toggleEditorMode, + onToggleFavorite: toggleSelectedFavorite, + } + + return +} + +type MobileRightPanel = 'ai' | 'properties' | 'settings' + +type MobileAppShellProps = { + activeVault: MobileVaultMetadata + aiFailed: boolean + aiSettings: MobileAiSettings + allNotes: MobileNote[] + compactNavigation: ReturnType + createNoteFailed: boolean + editorMode: 'raw' | 'rich' + gitHubOAuthClientIdState: 'configured' | 'missing' + gitSyncPlan: MobileGitSyncPlan + isCreatingNote: boolean + isRemoteSetupOpen: boolean + isSavingAiSettings: boolean + isSavingProperties: boolean + isTablet: boolean + listTitle: string + notes: MobileNote[] + propertiesFailed: boolean + remoteSetupFailed: boolean + remoteSetupIsSaving: boolean + remoteSetupUrl: string + rightPanel: MobileRightPanel + runtimeLoadFailed: boolean + saveState: MobileEditorSaveState + selectedNote: MobileNote + selectedNoteId: string + selectedProvider: MobileAiProvider | null + showsProperties: boolean + sidebarSections: ReturnType + sidebarSelection: MobileSidebarSelection + onAddAiProvider: (draft: MobileAiProviderDraft) => Promise + onCancelRemoteSetup: () => void + onChangeProperties: (patch: MobileNotePropertyPatch) => void + onChangeRemoteUrl: (remoteUrl: string) => void + onCreateNote: () => void + onDeleteNote?: () => void + onDraftChange: (draft: MobileEditorDraft) => void + onGitSyncAction: () => void + onNavigate: (event: CompactNavigationEvent) => void + onOpenAi: () => void + onOpenAiSettings: () => void + onOpenProperties: () => void + onOpenRemoteSetup: () => void + onRawMarkdownChange: (markdown: string) => void + onRemoveAiProvider: (providerId: string) => Promise + onRetryRuntimeLoad: () => void + onSelectNote: (note: MobileNote) => void + onSelectNoteId: (noteId: string) => void + onSelectSidebar: (selection: MobileSidebarSelection) => void + onSendAiPrompt: (prompt: string, provider: MobileAiProvider) => Promise + onSubmitRemoteSetup: () => void + onToggleArchive: () => void + onToggleEditorMode: () => void + onToggleFavorite: () => void +} + +function MobileAppShell(props: MobileAppShellProps) { return ( - {isTablet ? ( - - - - setRightPanel('ai')} - onOpenProperties={() => setRightPanel('properties')} - onRawMarkdownChange={saveRawMarkdown} - onToggleArchive={toggleSelectedArchive} - onToggleEditorMode={toggleEditorMode} - onToggleFavorite={toggleSelectedFavorite} - /> - {showsProperties && rightPanel === 'properties' ? ( - - ) : null} - {showsProperties && rightPanel === 'ai' ? : null} - - ) : ( - setCompactNavigation((state) => transitionCompactNavigation(state, event))} - onDeleteNote={deleteFlow.canDelete ? deleteFlow.deleteSelectedNote : undefined} - onDraftChange={saveDraft} - onRawMarkdownChange={saveRawMarkdown} - onSelectSidebar={selectSidebar} - onToggleArchive={toggleSelectedArchive} - onToggleEditorMode={toggleEditorMode} - onToggleFavorite={toggleSelectedFavorite} - createNoteFailed={createFlow.failed} - isCreatingNote={createFlow.isCreating} - runtimeLoadFailed={runtimeLoader.failed} - onCreateNote={createFlow.create} - onGitSyncAction={gitSyncFlow.runPrimaryAction} - onRetryRuntimeLoad={runtimeLoader.retry} - onSelectNote={selectNote} - propertiesFailed={propertiesFlow.failed} - isSavingProperties={propertiesFlow.isSaving} - onChangeProperties={propertiesFlow.saveProperties} - onOpenRemoteSetup={remoteSetupFlow.open} - /> - )} - {remoteSetupFlow.isOpen ? ( - - ) : null} + {props.isTablet ? : } + ) } +function TabletShell(props: MobileAppShellProps) { + return ( + + + + + + + ) +} + +function TabletRightPanel(props: MobileAppShellProps) { + if (!props.showsProperties) return null + + if (props.rightPanel === 'ai') { + return ( + + ) + } + + if (props.rightPanel === 'settings') { + return ( + + ) + } + + return ( + + ) +} + +function CompactAppShell(props: MobileAppShellProps) { + return ( + + ) +} + +function RemoteSetupPrompt(props: MobileAppShellProps) { + if (!props.isRemoteSetupOpen) return null + + return ( + + ) +} + type CompactShellProps = { activePanel: CompactPanel activeVault: MobileVaultMetadata diff --git a/apps/mobile/src/MobilePropertiesPanel.tsx b/apps/mobile/src/MobilePropertiesPanel.tsx index 86bc5e4c..d7471771 100644 --- a/apps/mobile/src/MobilePropertiesPanel.tsx +++ b/apps/mobile/src/MobilePropertiesPanel.tsx @@ -5,6 +5,14 @@ import type { MobileNote } from './demoData' import { MobileEditablePropertyPickers } from './MobileEditablePropertyPickers' import type { MobileNotePropertyPatch } from './mobileNoteProperties' import { nextMobilePropertyPicker, type MobilePropertyPickerKey } from './mobilePropertyPicker' +import { + canonicalMobileRelationshipRef, + filterMobileRelationshipRef, + hasMobileRelationshipRef, + mobileRelationshipDisplayLabel, + resolveMobileRelationshipNote, + uniqueMobileRelationshipRefs, +} from './mobileRelationshipRefs' import { mobileNoteSuggestions } from './mobileWikilinkAutocomplete' import { mobileRelationshipAppearance } from './mobileTypeAppearance' import { styles } from './styles' @@ -112,10 +120,13 @@ function RelationshipGroup({ }) { const [query, setQuery] = useState('') const [isAdding, setIsAdding] = useState(false) - const uniqueTargets = [...new Set(targets)] + const uniqueTargets = uniqueMobileRelationshipRefs(targets) const suggestions = isAdding ? mobileNoteSuggestions({ notes, query }) : [] - const addTarget = (target: string) => { - onChangeTargets?.([...writableTargets, target]) + const addTarget = (value: string) => { + const target = canonicalMobileRelationshipRef({ notes, value }) + if (!target) return + + onChangeTargets?.(uniqueMobileRelationshipRefs([...writableTargets, target])) setQuery('') setIsAdding(false) } @@ -127,8 +138,10 @@ function RelationshipGroup({ {uniqueTargets.map((target) => ( onChangeTargets(writableTargets.filter((item) => item !== target)) : undefined} + note={resolveMobileRelationshipNote({ notes, target })} + onRemove={hasMobileRelationshipRef({ target, values: writableTargets }) && onChangeTargets + ? () => onChangeTargets(filterMobileRelationshipRef({ target, values: writableTargets })) + : undefined} target={target} onOpenNote={onOpenNote} /> @@ -190,7 +203,7 @@ function RelationshipAddBox({ {suggestions.map((suggestion) => ( onAddTarget(suggestion.id)} + onPress={() => onAddTarget(suggestion.title)} style={({ pressed }) => [styles.relationshipSuggestion, pressed ? styles.pressed : null]} > {suggestion.title} @@ -334,7 +347,7 @@ function RelationshipChip({ target: string }) { const appearance = mobileRelationshipAppearance(note?.type) - const chip = {note?.title ?? target} + const chip = {note?.title ?? mobileRelationshipDisplayLabel(target)} const content = ( <> @@ -363,21 +376,6 @@ function RelationshipChip({ ) } -function findRelationshipNote({ - notes, - target, -}: { - notes: MobileNote[] - target: string -}) { - const normalizedTarget = normalizeRelationshipTarget(target) - return notes.find((note) => normalizeRelationshipTarget(note.id) === normalizedTarget || normalizeRelationshipTarget(note.title) === normalizedTarget) -} - -function normalizeRelationshipTarget(target: string) { - return target.trim().toLowerCase() -} - function formatRelationshipLabel(key: string) { return key.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()) } diff --git a/apps/mobile/src/mobileAiClient.test.ts b/apps/mobile/src/mobileAiClient.test.ts index 07084242..20b7827f 100644 --- a/apps/mobile/src/mobileAiClient.test.ts +++ b/apps/mobile/src/mobileAiClient.test.ts @@ -15,10 +15,15 @@ describe('mobile AI client', () => { await expect(sendMobileAiRequest({ apiKey: 'key', - baseUrl: 'https://api.example.com/v1/', - model: 'model', note: note(), prompt: 'Summarize', + provider: { + baseUrl: 'https://api.example.com/v1/', + id: 'provider', + kind: 'open_ai_compatible', + modelId: 'model', + name: 'Provider', + }, })).resolves.toBe('Answer') expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/chat/completions', expect.objectContaining({ diff --git a/apps/mobile/src/mobileAiClient.ts b/apps/mobile/src/mobileAiClient.ts index 374d4804..81bb31bd 100644 --- a/apps/mobile/src/mobileAiClient.ts +++ b/apps/mobile/src/mobileAiClient.ts @@ -1,15 +1,15 @@ import type { MobileNote } from './mobileNoteProjection' +import type { MobileAiProvider } from './mobileAiSettings' export type MobileAiRequest = { apiKey: string - baseUrl: string - model: string note: MobileNote prompt: string + provider: MobileAiProvider } export async function sendMobileAiRequest(request: MobileAiRequest) { - const response = await fetch(`${request.baseUrl.replace(/\/$/, '')}/chat/completions`, { + const response = await fetch(`${request.provider.baseUrl.replace(/\/$/, '')}/chat/completions`, { body: JSON.stringify({ messages: [ { @@ -23,7 +23,7 @@ export async function sendMobileAiRequest(request: MobileAiRequest) { }, { content: request.prompt, role: 'user' }, ], - model: request.model, + model: request.provider.modelId, }), headers: { Authorization: `Bearer ${request.apiKey}`, diff --git a/apps/mobile/src/mobileAiProviderSecretStorage.test.ts b/apps/mobile/src/mobileAiProviderSecretStorage.test.ts new file mode 100644 index 00000000..df226cae --- /dev/null +++ b/apps/mobile/src/mobileAiProviderSecretStorage.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { createMobileAiProviderSecretStorage } from './mobileAiProviderSecretStorage' + +describe('mobile AI provider secret storage', () => { + it('stores API keys in secure provider-scoped records', async () => { + const secrets = new Map() + const storage = createMobileAiProviderSecretStorage({ + deleteItemAsync: async (key) => { + secrets.delete(key) + }, + getItemAsync: async (key) => secrets.get(key) ?? null, + setItemAsync: async (key, value) => { + secrets.set(key, value) + }, + }) + + await storage.saveApiKey('Open-AI', 'secret') + + expect(await storage.loadApiKey('open-ai')).toBe('secret') + + await storage.removeApiKey('open-ai') + + expect(await storage.loadApiKey('open-ai')).toBeNull() + }) +}) diff --git a/apps/mobile/src/mobileAiProviderSecretStorage.ts b/apps/mobile/src/mobileAiProviderSecretStorage.ts new file mode 100644 index 00000000..01cbf517 --- /dev/null +++ b/apps/mobile/src/mobileAiProviderSecretStorage.ts @@ -0,0 +1,29 @@ +export type MobileAiProviderSecretStore = { + deleteItemAsync: (key: string) => Promise + getItemAsync: (key: string) => Promise + setItemAsync: (key: string, value: string) => Promise +} + +export type MobileAiProviderSecretStorage = { + loadApiKey: (providerId: string) => Promise + removeApiKey: (providerId: string) => Promise + saveApiKey: (providerId: string, apiKey: string) => Promise +} + +export function createMobileAiProviderSecretStorage( + secureStore: MobileAiProviderSecretStore, +): MobileAiProviderSecretStorage { + return { + loadApiKey: async (providerId) => secureStore.getItemAsync(providerKey(providerId)), + removeApiKey: async (providerId) => { + await secureStore.deleteItemAsync(providerKey(providerId)) + }, + saveApiKey: async (providerId, apiKey) => { + await secureStore.setItemAsync(providerKey(providerId), apiKey) + }, + } +} + +function providerKey(providerId: string) { + return ['tolaria', 'ai-provider', providerId.trim().toLowerCase(), 'api-key'].join(':') +} diff --git a/apps/mobile/src/mobileAiSettings.test.ts b/apps/mobile/src/mobileAiSettings.test.ts new file mode 100644 index 00000000..4ea10dff --- /dev/null +++ b/apps/mobile/src/mobileAiSettings.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + buildMobileAiProvider, + normalizeMobileAiSettings, + removeMobileAiProvider, + selectedMobileAiProvider, + upsertMobileAiProvider, +} from './mobileAiSettings' + +describe('mobile AI settings', () => { + it('builds API providers from presets without storing API keys', () => { + expect(buildMobileAiProvider({ + draft: { + apiKey: 'secret', + kind: 'open_ai', + modelId: ' gpt-4.1-mini ', + name: '', + }, + providerId: 'open-ai', + })).toEqual({ + baseUrl: 'https://api.openai.com/v1', + id: 'open-ai', + kind: 'open_ai', + modelId: 'gpt-4.1-mini', + name: 'OpenAI', + }) + }) + + it('normalizes persisted settings and selected provider', () => { + const settings = normalizeMobileAiSettings({ + defaultProviderId: 'open-ai', + providers: [{ + baseUrl: 'https://api.openai.com/v1', + id: 'open-ai', + kind: 'open_ai', + modelId: 'gpt-4.1-mini', + name: 'OpenAI', + }], + }) + + expect(selectedMobileAiProvider(settings)?.id).toBe('open-ai') + }) + + it('upserts and removes providers while maintaining the default target', () => { + const settings = upsertMobileAiProvider({ + provider: provider('one'), + settings: { defaultProviderId: null, providers: [] }, + }) + + expect(settings.defaultProviderId).toBe('one') + expect(removeMobileAiProvider({ providerId: 'one', settings })).toEqual({ + defaultProviderId: null, + providers: [], + }) + }) +}) + +function provider(id: string) { + return { + baseUrl: 'https://api.openai.com/v1', + id, + kind: 'open_ai' as const, + modelId: 'gpt-4.1-mini', + name: 'OpenAI', + } +} diff --git a/apps/mobile/src/mobileAiSettings.ts b/apps/mobile/src/mobileAiSettings.ts new file mode 100644 index 00000000..cd97edd2 --- /dev/null +++ b/apps/mobile/src/mobileAiSettings.ts @@ -0,0 +1,166 @@ +export type MobileAiProviderKind = 'anthropic' | 'gemini' | 'open_ai' | 'open_ai_compatible' | 'open_router' + +export type MobileAiProvider = { + baseUrl: string + id: string + kind: MobileAiProviderKind + modelId: string + name: string +} + +export type MobileAiSettings = { + defaultProviderId: string | null + providers: MobileAiProvider[] +} + +export type MobileAiProviderDraft = { + apiKey: string + kind: MobileAiProviderKind + modelId: string + name: string +} + +export const defaultMobileAiSettings: MobileAiSettings = { + defaultProviderId: null, + providers: [], +} + +export const mobileAiProviderPresets: Record = { + anthropic: { + baseUrl: 'https://api.anthropic.com/v1', + name: 'Anthropic', + placeholder: 'claude-3-5-sonnet-latest', + }, + gemini: { + baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', + name: 'Gemini', + placeholder: 'gemini-2.5-flash', + }, + open_ai: { + baseUrl: 'https://api.openai.com/v1', + name: 'OpenAI', + placeholder: 'gpt-4.1-mini', + }, + open_ai_compatible: { + baseUrl: 'https://api.example.com/v1', + name: 'Custom provider', + placeholder: 'model-id', + }, + open_router: { + baseUrl: 'https://openrouter.ai/api/v1', + name: 'OpenRouter', + placeholder: 'openai/gpt-4.1-mini', + }, +} + +export function buildMobileAiProvider({ + draft, + providerId, +}: { + draft: MobileAiProviderDraft + providerId: string +}): MobileAiProvider { + const preset = mobileAiProviderPresets[draft.kind] + return { + baseUrl: preset.baseUrl, + id: providerId, + kind: draft.kind, + modelId: draft.modelId.trim(), + name: draft.name.trim() || preset.name, + } +} + +export function normalizeMobileAiSettings(value: unknown): MobileAiSettings { + if (!isSettingsRecord(value)) { + return defaultMobileAiSettings + } + + const providers = value.providers.flatMap(normalizeMobileAiProvider) + return { + defaultProviderId: defaultProviderId({ providers, value: value.defaultProviderId }), + providers, + } +} + +export function selectedMobileAiProvider(settings: MobileAiSettings) { + return settings.providers.find((provider) => provider.id === settings.defaultProviderId) ?? settings.providers[0] ?? null +} + +export function upsertMobileAiProvider({ + provider, + settings, +}: { + provider: MobileAiProvider + settings: MobileAiSettings +}): MobileAiSettings { + const providers = [provider, ...settings.providers.filter((item) => item.id !== provider.id)] + return { defaultProviderId: provider.id, providers } +} + +export function removeMobileAiProvider({ + providerId, + settings, +}: { + providerId: string + settings: MobileAiSettings +}): MobileAiSettings { + const providers = settings.providers.filter((provider) => provider.id !== providerId) + return { + defaultProviderId: settings.defaultProviderId === providerId ? providers[0]?.id ?? null : settings.defaultProviderId, + providers, + } +} + +function defaultProviderId({ + providers, + value, +}: { + providers: MobileAiProvider[] + value: unknown +}) { + return typeof value === 'string' && providers.some((provider) => provider.id === value) + ? value + : providers[0]?.id ?? null +} + +function normalizeMobileAiProvider(value: unknown): MobileAiProvider[] { + if (!isProviderRecord(value)) { + return [] + } + + return [{ + baseUrl: value.baseUrl.trim(), + id: value.id.trim(), + kind: value.kind, + modelId: value.modelId.trim(), + name: value.name.trim(), + }] +} + +function isSettingsRecord(value: unknown): value is { defaultProviderId?: unknown; providers: unknown[] } { + return typeof value === 'object' + && value !== null + && Array.isArray((value as { providers?: unknown }).providers) +} + +function isProviderRecord(value: unknown): value is MobileAiProvider { + return typeof value === 'object' + && value !== null + && hasText((value as MobileAiProvider).baseUrl) + && hasText((value as MobileAiProvider).id) + && isProviderKind((value as MobileAiProvider).kind) + && hasText((value as MobileAiProvider).modelId) + && hasText((value as MobileAiProvider).name) +} + +function hasText(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +function isProviderKind(value: unknown): value is MobileAiProviderKind { + return value === 'anthropic' + || value === 'gemini' + || value === 'open_ai' + || value === 'open_ai_compatible' + || value === 'open_router' +} diff --git a/apps/mobile/src/mobileAiSettingsStorage.test.ts b/apps/mobile/src/mobileAiSettingsStorage.test.ts new file mode 100644 index 00000000..0e90e9e3 --- /dev/null +++ b/apps/mobile/src/mobileAiSettingsStorage.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { createMobileAiSettingsStorage } from './mobileAiSettingsStorage' + +describe('mobile AI settings storage', () => { + it('loads default settings when no file exists and saves normalized settings', async () => { + const files = new Map() + const storage = createMobileAiSettingsStorage({ + documentDirectory: 'file:///docs', + getInfoAsync: async (uri) => ({ exists: files.has(uri) }), + makeDirectoryAsync: async (uri) => { + files.set(uri, '') + }, + readAsStringAsync: async (uri) => files.get(uri) ?? '', + writeAsStringAsync: async (uri, content) => { + files.set(uri, content) + }, + }) + + expect(await storage.load()).toEqual({ defaultProviderId: null, providers: [] }) + + await storage.save({ + defaultProviderId: 'provider', + providers: [{ + baseUrl: 'https://api.openai.com/v1', + id: 'provider', + kind: 'open_ai', + modelId: 'gpt-4.1-mini', + name: 'OpenAI', + }], + }) + + expect(await storage.load()).toMatchObject({ defaultProviderId: 'provider' }) + }) +}) diff --git a/apps/mobile/src/mobileAiSettingsStorage.ts b/apps/mobile/src/mobileAiSettingsStorage.ts new file mode 100644 index 00000000..e7da73a7 --- /dev/null +++ b/apps/mobile/src/mobileAiSettingsStorage.ts @@ -0,0 +1,82 @@ +import { defaultMobileAiSettings, normalizeMobileAiSettings, type MobileAiSettings } from './mobileAiSettings' + +export type MobileAiSettingsFileInfo = { + exists: boolean + isDirectory?: boolean +} + +export type MobileAiSettingsFileSystem = { + documentDirectory: string | null + getInfoAsync: (uri: string) => Promise + makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise + readAsStringAsync: (uri: string) => Promise + writeAsStringAsync: (uri: string, content: string) => Promise +} + +export type MobileAiSettingsStorage = { + load: () => Promise + save: (settings: MobileAiSettings) => Promise +} + +export function createMobileAiSettingsStorage( + fileSystem: MobileAiSettingsFileSystem, +): MobileAiSettingsStorage { + return { + load: async () => loadMobileAiSettings(fileSystem), + save: async (settings) => saveMobileAiSettings({ fileSystem, settings }), + } +} + +async function loadMobileAiSettings(fileSystem: MobileAiSettingsFileSystem) { + const fileUri = settingsFileUri(fileSystem) + const info = await fileSystem.getInfoAsync(fileUri) + if (!info.exists || info.isDirectory) { + return defaultMobileAiSettings + } + + return parseMobileAiSettings(await fileSystem.readAsStringAsync(fileUri)) +} + +async function saveMobileAiSettings({ + fileSystem, + settings, +}: { + fileSystem: MobileAiSettingsFileSystem + settings: MobileAiSettings +}) { + await ensureDirectory({ fileSystem, uri: settingsRootUri(fileSystem) }) + await fileSystem.writeAsStringAsync(settingsFileUri(fileSystem), JSON.stringify(settings)) +} + +function parseMobileAiSettings(content: string) { + try { + return normalizeMobileAiSettings(JSON.parse(content)) + } catch { + return defaultMobileAiSettings + } +} + +async function ensureDirectory({ + fileSystem, + uri, +}: { + fileSystem: MobileAiSettingsFileSystem + uri: string +}) { + const info = await fileSystem.getInfoAsync(uri) + if (!info.exists) { + await fileSystem.makeDirectoryAsync(uri, { intermediates: true }) + } +} + +function settingsFileUri(fileSystem: MobileAiSettingsFileSystem) { + return `${settingsRootUri(fileSystem)}/ai-settings.json` +} + +function settingsRootUri(fileSystem: MobileAiSettingsFileSystem) { + if (!fileSystem.documentDirectory) { + throw new Error('Expo FileSystem documentDirectory is unavailable') + } + + return `${fileSystem.documentDirectory.replace(/\/+$/, '')}/state` +} diff --git a/apps/mobile/src/mobileNativeAiProviderSecretStorage.ts b/apps/mobile/src/mobileNativeAiProviderSecretStorage.ts new file mode 100644 index 00000000..7f159892 --- /dev/null +++ b/apps/mobile/src/mobileNativeAiProviderSecretStorage.ts @@ -0,0 +1,6 @@ +import * as SecureStore from 'expo-secure-store' +import { createMobileAiProviderSecretStorage } from './mobileAiProviderSecretStorage' + +export function createNativeMobileAiProviderSecretStorage() { + return createMobileAiProviderSecretStorage(SecureStore) +} diff --git a/apps/mobile/src/mobileNativeAiSettingsStorage.ts b/apps/mobile/src/mobileNativeAiSettingsStorage.ts new file mode 100644 index 00000000..6a5e5724 --- /dev/null +++ b/apps/mobile/src/mobileNativeAiSettingsStorage.ts @@ -0,0 +1,6 @@ +import * as FileSystem from 'expo-file-system/legacy' +import { createMobileAiSettingsStorage } from './mobileAiSettingsStorage' + +export function createNativeMobileAiSettingsStorage() { + return createMobileAiSettingsStorage(FileSystem) +} diff --git a/apps/mobile/src/mobileRelationshipRefs.test.ts b/apps/mobile/src/mobileRelationshipRefs.test.ts new file mode 100644 index 00000000..86bdc9f7 --- /dev/null +++ b/apps/mobile/src/mobileRelationshipRefs.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + canonicalMobileRelationshipRef, + filterMobileRelationshipRef, + mobileRelationshipDisplayLabel, + resolveMobileRelationshipNote, + uniqueMobileRelationshipRefs, +} from './mobileRelationshipRefs' +import type { MobileNote } from './mobileNoteProjection' + +describe('mobile relationship refs', () => { + it('canonicalizes selected notes as desktop-compatible wikilinks', () => { + expect(canonicalMobileRelationshipRef({ + notes: [note({ id: 'projects/tolaria-mvp', title: 'Tolaria MVP' })], + value: 'Tolaria MVP', + })).toBe('[[projects/tolaria-mvp|Tolaria MVP]]') + }) + + it('wraps unmatched text instead of writing plain relationship values', () => { + expect(canonicalMobileRelationshipRef({ notes: [], value: 'Release Notes' })).toBe('[[release-notes|Release Notes]]') + }) + + it('resolves aliased wikilinks and plain titles to notes', () => { + const notes = [note({ id: 'people/luca', title: 'Luca' })] + + expect(resolveMobileRelationshipNote({ notes, target: '[[people/luca|Luca]]' })?.id).toBe('people/luca') + expect(resolveMobileRelationshipNote({ notes, target: 'Luca' })?.id).toBe('people/luca') + }) + + it('deduplicates and removes relationship refs by canonical target', () => { + const values = uniqueMobileRelationshipRefs(['Tolaria MVP', '[[tolaria-mvp|Tolaria MVP]]', '[[other]]']) + + expect(values).toEqual(['[[tolaria-mvp|Tolaria MVP]]', '[[other]]']) + expect(filterMobileRelationshipRef({ target: 'Tolaria MVP', values })).toEqual(['[[other]]']) + }) + + it('renders aliases before falling back to humanized targets', () => { + expect(mobileRelationshipDisplayLabel('[[projects/tolaria-mvp|Tolaria MVP]]')).toBe('Tolaria MVP') + expect(mobileRelationshipDisplayLabel('[[release-notes]]')).toBe('Release Notes') + }) +}) + +function note({ id, title }: { id: string; title: string }): MobileNote { + return { + archived: false, + backlinks: [], + belongsTo: [], + content: `# ${title}`, + customProperties: {}, + date: '', + favorite: false, + favoriteIndex: null, + has: [], + icon: 'file-text', + id, + modified: '', + outgoingLinks: [], + relatedTo: [], + relationships: {}, + snippet: '', + status: undefined, + tags: [], + title, + type: 'Note', + words: 1, + } +} diff --git a/apps/mobile/src/mobileRelationshipRefs.ts b/apps/mobile/src/mobileRelationshipRefs.ts new file mode 100644 index 00000000..a43bd995 --- /dev/null +++ b/apps/mobile/src/mobileRelationshipRefs.ts @@ -0,0 +1,115 @@ +import type { MobileNote } from './mobileNoteProjection' + +type MobileRelationshipInput = string +type MobileRelationshipRef = string +type MobileRelationshipRefs = MobileRelationshipRef[] +type MobileRelationshipKey = string + +export function canonicalMobileRelationshipRef({ + notes, + value, +}: { + notes: MobileNote[] + value: MobileRelationshipInput +}) { + const trimmed = value.trim() + if (!trimmed) return null + + const note = resolveMobileRelationshipNote({ notes, target: trimmed }) + if (note) return mobileWikilinkForNote(note) + if (isMobileWikilink(trimmed)) return trimmed + + const slug = slugifyMobileWikilinkTarget(trimmed) + return `[[${slug}|${trimmed}]]` +} + +export function filterMobileRelationshipRef({ + target, + values, +}: { + target: MobileRelationshipInput + values: MobileRelationshipRefs +}) { + const targetKey = mobileRelationshipRefKey(target) + return values.filter((value) => mobileRelationshipRefKey(value) !== targetKey) +} + +export function hasMobileRelationshipRef({ + target, + values, +}: { + target: MobileRelationshipInput + values: MobileRelationshipRefs +}) { + return filterMobileRelationshipRef({ target, values }).length !== values.length +} + +export function mobileRelationshipDisplayLabel(value: MobileRelationshipInput) { + const inner = mobileWikilinkInner(value) + const [, alias] = inner.split('|') + return alias?.trim() || titleFromTarget(inner.split('|')[0] ?? value) +} + +export function mobileWikilinkForNote(note: Pick) { + return `[[${note.id}|${note.title}]]` +} + +export function resolveMobileRelationshipNote({ + notes, + target, +}: { + notes: MobileNote[] + target: MobileRelationshipInput +}) { + const targetKeys = mobileRelationshipLookupKeys(target) + return notes.find((note) => noteKeys(note).some((key) => targetKeys.has(key))) +} + +export function uniqueMobileRelationshipRefs(values: MobileRelationshipRefs) { + const refs = new Map() + for (const value of values) { + refs.set(mobileRelationshipRefKey(value), value) + } + return [...refs.values()] +} + +function mobileRelationshipRefKey(value: MobileRelationshipInput): MobileRelationshipKey { + return slugifyMobileWikilinkTarget(mobileWikilinkInner(value).split('|')[0] ?? value) +} + +function mobileRelationshipLookupKeys(value: MobileRelationshipInput) { + const inner = mobileWikilinkInner(value) + const [target, alias] = inner.split('|') + return new Set([target, alias, inner].filter(isText).map(slugifyMobileWikilinkTarget)) +} + +function noteKeys(note: Pick) { + return [note.id, note.title].map(slugifyMobileWikilinkTarget) +} + +function mobileWikilinkInner(value: MobileRelationshipInput): MobileRelationshipRef { + return value.trim().replace(/^\[\[|\]\]$/g, '') +} + +function titleFromTarget(value: MobileRelationshipRef) { + const lastSegment = value.split('/').pop() ?? value + return lastSegment.replace(/-/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()) +} + +function slugifyMobileWikilinkTarget(value: MobileRelationshipInput): MobileRelationshipKey { + const slug = value + .normalize('NFKC') + .toLocaleLowerCase() + .trim() + .replace(/[^\p{Letter}\p{Number}/]+/gu, '-') + .replace(/(^-|-$)/g, '') + return slug || 'untitled' +} + +function isMobileWikilink(value: MobileRelationshipInput) { + return value.startsWith('[[') && value.endsWith(']]') && mobileWikilinkInner(value).length > 0 +} + +function isText(value: string | undefined): value is string { + return typeof value === 'string' && value.trim().length > 0 +} diff --git a/apps/mobile/src/mobileSidebarNavigation.test.ts b/apps/mobile/src/mobileSidebarNavigation.test.ts index af661238..6250febe 100644 --- a/apps/mobile/src/mobileSidebarNavigation.test.ts +++ b/apps/mobile/src/mobileSidebarNavigation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { createMobileSidebarSections, filterNotesForSidebarSelection, mobileSidebarTitle } from './mobileSidebarNavigation' import type { MobileNote } from './mobileNoteProjection' +import { mobileFixtureViewDefinitions } from './mobileViewFilters' describe('mobile sidebar navigation', () => { it('filters inbox, all notes, archive, and type selections', () => { @@ -24,7 +25,11 @@ describe('mobile sidebar navigation', () => { ] expect(filterNotesForSidebarSelection({ notes, selection: { kind: 'library', id: 'favorites' } }).map((item) => item.id)).toEqual(['essay']) - expect(filterNotesForSidebarSelection({ notes, selection: { kind: 'view', id: 'active-drafts' } }).map((item) => item.id)).toEqual(['draft']) + expect(filterNotesForSidebarSelection({ + notes, + selection: { kind: 'view', id: 'active-drafts' }, + views: mobileFixtureViewDefinitions, + }).map((item) => item.id)).toEqual(['draft']) }) it('builds sidebar sections from current local notes', () => { @@ -40,7 +45,14 @@ describe('mobile sidebar navigation', () => { ['Archive', 1], ['Favorites', 0], ]) - expect(sections[2].items.map((item) => item.label)).toEqual(['Essays', 'Projects']) + expect(sections[1].items.map((item) => item.label)).toEqual(['Essays', 'Projects']) + }) + + it('does not expose fixture views unless persisted view definitions are supplied', () => { + expect(createMobileSidebarSections([note({ id: 'draft', status: 'Draft', type: 'Essay' })]).map((section) => section.title)).toEqual(['Library', 'Types']) + expect(createMobileSidebarSections([ + note({ id: 'draft', status: 'Draft', type: 'Essay' }), + ], mobileFixtureViewDefinitions).map((section) => section.title)).toEqual(['Library', 'Views', 'Types']) }) it('formats list titles for library and type selections', () => { diff --git a/apps/mobile/src/mobileSidebarNavigation.ts b/apps/mobile/src/mobileSidebarNavigation.ts index ee3dd8ec..e1d71e0e 100644 --- a/apps/mobile/src/mobileSidebarNavigation.ts +++ b/apps/mobile/src/mobileSidebarNavigation.ts @@ -1,6 +1,6 @@ import type { IconName } from './NamedIcon' import type { MobileNote } from './mobileNoteProjection' -import { evaluateMobileView, mobileViewDefinitions } from './mobileViewFilters' +import { evaluateMobileView, type MobileViewDefinition } from './mobileViewFilters' export type MobileSidebarSelection = | { kind: 'library'; id: 'all' | 'archive' | 'favorites' | 'inbox' } @@ -21,7 +21,10 @@ export type MobileSidebarSection = { export const defaultMobileSidebarSelection: MobileSidebarSelection = { kind: 'library', id: 'inbox' } -export function createMobileSidebarSections(notes: MobileNote[]): MobileSidebarSection[] { +export function createMobileSidebarSections( + notes: MobileNote[], + views: MobileViewDefinition[] = [], +): MobileSidebarSection[] { return [ { title: 'Library', @@ -32,15 +35,7 @@ export function createMobileSidebarSections(notes: MobileNote[]): MobileSidebarS libraryItem({ count: favoriteNotes(notes).length, icon: 'star', id: 'favorites', label: 'Favorites' }), ], }, - { - title: 'Views', - items: mobileViewDefinitions.map((view) => ({ - count: evaluateMobileView({ notes: activeNotes(notes), view }).length, - icon: view.icon as IconName, - label: view.name, - selection: { kind: 'view', id: view.id }, - })), - }, + ...viewSections({ notes, views }), { title: 'Types', items: noteTypes(notes).map((type) => ({ @@ -56,16 +51,18 @@ export function createMobileSidebarSections(notes: MobileNote[]): MobileSidebarS export function filterNotesForSidebarSelection({ notes, selection, + views = [], }: { notes: MobileNote[] selection: MobileSidebarSelection + views?: MobileViewDefinition[] }) { if (selection.kind === 'type') { return activeNotes(notes).filter((note) => note.type === selection.type) } if (selection.kind === 'view') { - const view = mobileViewDefinitions.find((definition) => definition.id === selection.id) + const view = views.find((definition) => definition.id === selection.id) return view ? evaluateMobileView({ notes: activeNotes(notes), view }) : [] } @@ -81,13 +78,13 @@ export function filterNotesForSidebarSelection({ } } -export function mobileSidebarTitle(selection: MobileSidebarSelection) { +export function mobileSidebarTitle(selection: MobileSidebarSelection, views: MobileViewDefinition[] = []) { if (selection.kind === 'type') { return pluralTypeLabel(selection.type) } if (selection.kind === 'view') { - return mobileViewDefinitions.find((definition) => definition.id === selection.id)?.name ?? 'View' + return views.find((definition) => definition.id === selection.id)?.name ?? 'View' } return libraryTitle(selection.id) @@ -123,6 +120,26 @@ function libraryItem({ } } +function viewSections({ + notes, + views, +}: { + notes: MobileNote[] + views: MobileViewDefinition[] +}): MobileSidebarSection[] { + return views.length > 0 + ? [{ + title: 'Views', + items: views.map((view) => ({ + count: evaluateMobileView({ notes: activeNotes(notes), view }).length, + icon: view.icon as IconName, + label: view.name, + selection: { kind: 'view', id: view.id } as const, + })), + }] + : [] +} + function inboxNotes(notes: MobileNote[]) { return activeNotes(notes).filter((note) => !note.status || note.status === 'Draft') } diff --git a/apps/mobile/src/mobileViewFilters.ts b/apps/mobile/src/mobileViewFilters.ts index 94df75b2..83f1c3f2 100644 --- a/apps/mobile/src/mobileViewFilters.ts +++ b/apps/mobile/src/mobileViewFilters.ts @@ -29,7 +29,7 @@ export type MobileViewDefinition = { name: string } -export const mobileViewDefinitions: MobileViewDefinition[] = [ +export const mobileFixtureViewDefinitions: MobileViewDefinition[] = [ { color: '#3f7f5f', filters: { diff --git a/apps/mobile/src/mobileWikilinkAutocomplete.ts b/apps/mobile/src/mobileWikilinkAutocomplete.ts index f9905944..ade4165b 100644 --- a/apps/mobile/src/mobileWikilinkAutocomplete.ts +++ b/apps/mobile/src/mobileWikilinkAutocomplete.ts @@ -1,4 +1,5 @@ import type { MobileNote } from './mobileNoteProjection' +import { mobileWikilinkForNote } from './mobileRelationshipRefs' export type MobileWikilinkQuery = { end: number @@ -50,7 +51,7 @@ export function insertMobileWikilink({ note: MobileNote query: MobileWikilinkQuery }) { - const wikilink = `[[${note.id}|${note.title}]]` + const wikilink = mobileWikilinkForNote(note) return `${markdown.slice(0, query.start)}${wikilink}${markdown.slice(query.end)}` } diff --git a/apps/mobile/src/styles.ts b/apps/mobile/src/styles.ts index 9d96fcba..cf39b6f9 100644 --- a/apps/mobile/src/styles.ts +++ b/apps/mobile/src/styles.ts @@ -1,4 +1,7 @@ +import { aiControlStyles } from './styles/aiControlStyles' +import { aiProviderKindStyles } from './styles/aiProviderKindStyles' import { aiStyles } from './styles/aiStyles' +import { aiSettingsStyles } from './styles/aiSettingsStyles' import { breadcrumbStyles } from './styles/breadcrumbStyles' import { commonStyles } from './styles/commonStyles' import { customPropertyStyles } from './styles/customPropertyStyles' @@ -17,7 +20,10 @@ import { vaultManagementStyles } from './styles/vaultManagementStyles' import { vaultLoadStyles } from './styles/vaultLoadStyles' export const styles = { + ...aiControlStyles, + ...aiProviderKindStyles, ...aiStyles, + ...aiSettingsStyles, ...breadcrumbStyles, ...commonStyles, ...customPropertyStyles, diff --git a/apps/mobile/src/styles/aiControlStyles.ts b/apps/mobile/src/styles/aiControlStyles.ts new file mode 100644 index 00000000..98be3ef5 --- /dev/null +++ b/apps/mobile/src/styles/aiControlStyles.ts @@ -0,0 +1,55 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing } from '../theme' + +export const aiControlStyles = StyleSheet.create({ + aiInput: { + minHeight: 38, + borderColor: colors.border, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + color: colors.text, + fontSize: 14, + paddingHorizontal: spacing.md, + backgroundColor: colors.canvas, + }, + aiPrompt: { + minHeight: 126, + borderColor: colors.border, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + color: colors.text, + fontSize: 15, + lineHeight: 21, + padding: spacing.md, + backgroundColor: colors.canvas, + }, + aiSendButton: { + minHeight: 42, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + borderRadius: 999, + backgroundColor: colors.primary, + }, + aiSendButtonText: { + color: '#ffffff', + fontSize: 14, + fontWeight: '700', + }, + aiSettingsButton: { + minHeight: 42, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + borderRadius: 999, + paddingHorizontal: spacing.lg, + backgroundColor: colors.primary, + }, + aiSettingsButtonText: { + color: '#ffffff', + fontSize: 14, + fontWeight: '700', + }, +}) diff --git a/apps/mobile/src/styles/aiProviderKindStyles.ts b/apps/mobile/src/styles/aiProviderKindStyles.ts new file mode 100644 index 00000000..42d85d07 --- /dev/null +++ b/apps/mobile/src/styles/aiProviderKindStyles.ts @@ -0,0 +1,31 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing } from '../theme' + +export const aiProviderKindStyles = StyleSheet.create({ + aiProviderKindRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + aiProviderKindChip: { + minHeight: 32, + justifyContent: 'center', + borderColor: colors.border, + borderRadius: 999, + borderWidth: StyleSheet.hairlineWidth, + paddingHorizontal: spacing.md, + backgroundColor: colors.canvas, + }, + aiProviderKindChipSelected: { + borderColor: colors.primary, + backgroundColor: colors.primarySoft, + }, + aiProviderKindText: { + color: colors.textSoft, + fontSize: 13, + fontWeight: '600', + }, + aiProviderKindTextSelected: { + color: colors.primary, + }, +}) diff --git a/apps/mobile/src/styles/aiSettingsStyles.ts b/apps/mobile/src/styles/aiSettingsStyles.ts new file mode 100644 index 00000000..fd2e5018 --- /dev/null +++ b/apps/mobile/src/styles/aiSettingsStyles.ts @@ -0,0 +1,54 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing } from '../theme' + +export const aiSettingsStyles = StyleSheet.create({ + aiSettingsContent: { + gap: spacing.md, + padding: spacing.lg, + }, + aiSettingsSectionTitle: { + color: colors.text, + fontSize: 15, + fontWeight: '700', + }, + aiSettingsDescription: { + color: colors.mutedText, + fontSize: 13, + lineHeight: 19, + }, + aiSettingsEmpty: { + borderColor: colors.border, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + color: colors.mutedText, + fontSize: 13, + padding: spacing.md, + backgroundColor: colors.canvas, + }, + aiProviderList: { + gap: spacing.sm, + }, + aiProviderRow: { + minHeight: 52, + flexDirection: 'row', + alignItems: 'center', + borderColor: colors.border, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + paddingHorizontal: spacing.md, + backgroundColor: colors.canvas, + }, + aiProviderText: { + flex: 1, + }, + aiProviderTitle: { + color: colors.text, + fontSize: 14, + fontWeight: '700', + }, + aiProviderDetail: { + marginTop: 3, + color: colors.mutedText, + fontSize: 12, + }, +}) diff --git a/apps/mobile/src/styles/aiStyles.ts b/apps/mobile/src/styles/aiStyles.ts index 3eb0d695..b9a27d3c 100644 --- a/apps/mobile/src/styles/aiStyles.ts +++ b/apps/mobile/src/styles/aiStyles.ts @@ -6,40 +6,41 @@ export const aiStyles = StyleSheet.create({ gap: spacing.sm, padding: spacing.lg, }, - aiInput: { - minHeight: 38, + aiContextCard: { borderColor: colors.border, borderRadius: 10, borderWidth: StyleSheet.hairlineWidth, - color: colors.text, - fontSize: 14, - paddingHorizontal: spacing.md, - backgroundColor: colors.canvas, - }, - aiPrompt: { - minHeight: 126, - borderColor: colors.border, - borderRadius: 12, - borderWidth: StyleSheet.hairlineWidth, - color: colors.text, - fontSize: 15, - lineHeight: 21, padding: spacing.md, - backgroundColor: colors.canvas, + backgroundColor: colors.primarySoft, }, - aiSendButton: { - minHeight: 42, - flexDirection: 'row', + aiContextTitle: { + color: colors.text, + fontSize: 13, + fontWeight: '700', + }, + aiContextDetail: { + marginTop: 3, + color: colors.textSoft, + fontSize: 12, + }, + aiEmptyState: { + flex: 1, alignItems: 'center', justifyContent: 'center', - gap: spacing.sm, - borderRadius: 999, - backgroundColor: colors.primary, + padding: spacing.xl, }, - aiSendButtonText: { - color: '#ffffff', - fontSize: 14, + aiEmptyTitle: { + marginTop: spacing.md, + color: colors.text, + fontSize: 16, fontWeight: '700', + textAlign: 'center', + }, + aiEmptyDescription: { + color: colors.mutedText, + fontSize: 13, + lineHeight: 19, + textAlign: 'center', }, aiResponse: { marginTop: spacing.md, diff --git a/apps/mobile/src/useMobileAiSettingsFlow.ts b/apps/mobile/src/useMobileAiSettingsFlow.ts new file mode 100644 index 00000000..195c92b2 --- /dev/null +++ b/apps/mobile/src/useMobileAiSettingsFlow.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useState } from 'react' +import { sendMobileAiRequest } from './mobileAiClient' +import type { MobileNote } from './mobileNoteProjection' +import type { MobileAiProviderSecretStorage } from './mobileAiProviderSecretStorage' +import { + buildMobileAiProvider, + defaultMobileAiSettings, + removeMobileAiProvider, + selectedMobileAiProvider, + upsertMobileAiProvider, + type MobileAiProvider, + type MobileAiProviderDraft, + type MobileAiSettings, +} from './mobileAiSettings' +import type { MobileAiSettingsStorage } from './mobileAiSettingsStorage' + +export function useMobileAiSettingsFlow({ + secretStorage, + settingsStorage, +}: { + secretStorage: MobileAiProviderSecretStorage + settingsStorage: MobileAiSettingsStorage +}) { + const [failed, setFailed] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [settings, setSettings] = useState(defaultMobileAiSettings) + + useEffect(() => { + void settingsStorage.load().then(setSettings).catch(() => setFailed(true)) + }, [settingsStorage]) + + const addProvider = useCallback((draft: MobileAiProviderDraft) => { + const provider = buildMobileAiProvider({ draft, providerId: `${draft.kind}-${Date.now().toString(36)}` }) + return saveSettings({ + nextSettings: upsertMobileAiProvider({ provider, settings }), + secret: { apiKey: draft.apiKey, providerId: provider.id }, + secretStorage, + setFailed, + setIsSaving, + setSettings, + settingsStorage, + }) + }, [secretStorage, settings, settingsStorage]) + + const removeProvider = useCallback((providerId: string) => { + return saveSettings({ + nextSettings: removeMobileAiProvider({ providerId, settings }), + removeSecretProviderId: providerId, + secretStorage, + setFailed, + setIsSaving, + setSettings, + settingsStorage, + }) + }, [secretStorage, settings, settingsStorage]) + + const sendPrompt = useCallback(async ({ + note, + prompt, + provider, + }: { + note: MobileNote + prompt: string + provider: MobileAiProvider + }) => { + const apiKey = await secretStorage.loadApiKey(provider.id) + if (!apiKey) { + throw new Error('Missing API key') + } + + return sendMobileAiRequest({ apiKey, note, prompt, provider }) + }, [secretStorage]) + + return { + addProvider, + failed, + isSaving, + removeProvider, + selectedProvider: selectedMobileAiProvider(settings), + sendPrompt, + settings, + } +} + +async function saveSettings({ + nextSettings, + removeSecretProviderId, + secret, + secretStorage, + setFailed, + setIsSaving, + setSettings, + settingsStorage, +}: { + nextSettings: MobileAiSettings + removeSecretProviderId?: string + secret?: { apiKey: string; providerId: string } + secretStorage: MobileAiProviderSecretStorage + setFailed: (failed: boolean) => void + setIsSaving: (isSaving: boolean) => void + setSettings: (settings: MobileAiSettings) => void + settingsStorage: MobileAiSettingsStorage +}): Promise { + setFailed(false) + setIsSaving(true) + try { + if (secret) { + await secretStorage.saveApiKey(secret.providerId, secret.apiKey) + } + if (removeSecretProviderId) { + await secretStorage.removeApiKey(removeSecretProviderId) + } + await settingsStorage.save(nextSettings) + setSettings(nextSettings) + return true + } catch { + setFailed(true) + return false + } finally { + setIsSaving(false) + } +} diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index bcd6d84b..50acef68 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -8,7 +8,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Branch: `codex/mobile` - Active phase: Phase 4 - Editor V1 -- Active slice: Local workflow parity, relationships, views, favorites, and AI panel +- Active slice: Mobile quality remediation before new feature work - Push policy: commit locally; do not push unless explicitly requested - Validation target: iPad/iOS simulator first @@ -119,16 +119,20 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Added mobile saved views with nested `all` / `any` filter groups and desktop-like relationship/wikilink comparison semantics. - Added desktop-inspired type/relationship chip coloring for the mobile note list and properties panel. - Added an API-key-only AI side panel using an OpenAI-compatible `/chat/completions` request path, with no agents and no local models. +- Recorded `docs/MOBILE_QUALITY_AUDIT.md` after product review found the mobile implementation had drifted into prototype-grade feature coverage. The audit is now the quality gate for existing mobile surfaces before new feature work resumes. +- Corrected the AI product boundary: provider/model/API-key setup now lives in a mobile Settings surface, provider config persists as app-local settings, API keys go through SecureStore, and `MobileAiPanel` only consumes a configured API model target. +- Tightened relationship writes so mobile relationship additions save canonical wikilink refs, dedupe by canonical target, resolve aliases/plain titles for chip display/opening, and avoid introducing new loose id/string values from the properties UI. +- Demoted saved views from the primary mobile sidebar until real persisted view definitions exist; fixture nested-view definitions remain available to tests but are no longer exposed as if they were desktop-equivalent saved views. - Set the booted iPad simulator keyboard preferences to Italian (`it_IT@sw=QWERTY;hw=Automatic`). ## Next Action -Continue Phase 4 with local workflow parity and native QA: +Continue Phase 4 as a quality remediation pass before new feature work: -1. Install a simulator runtime matching the active Xcode SDK, or switch Xcode to one matching the installed iOS 17.5/18.6 runtimes, then retry the iOS development-client build. -2. Run iPad simulator QA over sidebar filters, favorites, editable properties, relationship add/remove, raw wikilink autocomplete, saved views, and AI panel input states. -3. Continue TenTap Markdown serialization coverage for any editor output observed in simulator QA. -4. Implement the native Git module behind `createNativeMobileGitTransport`, preferably Rust/libgit2 unless Expo native-module constraints block it. +1. Finish the properties panel quality pass: inverse relationships, suggested slots, clearer grouping, and demotion/removal of weak custom-property editing if it cannot match desktop semantics. +2. Add real persisted view definition loading before re-exposing Views in the sidebar. +3. Run iPad simulator QA over sidebar filters, favorites, editable properties, relationship add/remove, raw wikilink autocomplete, and AI/settings input states. +4. Install a simulator runtime matching the active Xcode SDK, or switch Xcode to one matching the installed iOS 17.5/18.6 runtimes, then retry the iOS development-client build. ## Verification Log @@ -194,6 +198,10 @@ Continue Phase 4 with local workflow parity and native QA: - CodeScene pre-commit safeguard passed for the current local change set. - iPad simulator keyboard preference now reads `KeyboardsCurrentAndNext = (it_IT@sw=QWERTY;hw=Automatic, it_IT@sw=QWERTY;hw=Automatic)`. - Native simulator QA is currently blocked by local platform tooling: Xcode has only the iOS 26.2 SDK installed, while CoreSimulator has iOS 17.5 and 18.6 runtimes, so `expo run:ios --device "iPad Pro 13-inch (M4)"` fails with xcodebuild error 70 because no matching destination is eligible. +- `pnpm --filter @tolaria/mobile typecheck` passed after the mobile quality remediation pass. +- `VITEST_MAX_THREADS=1 VITEST_MIN_THREADS=1 pnpm --filter @tolaria/mobile test` passed after the mobile quality remediation pass: 56 files / 183 tests. +- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export-quality-pass` passed after the mobile quality remediation pass. +- CodeScene touched-file checks and pre-commit safeguard passed after AI settings separation, relationship ref canonicalization, and saved-view demotion. - `pnpm --filter @tolaria/mobile test` passed after adding gesture support: 5 files / 15 tests. - `pnpm --filter @tolaria/mobile typecheck` passed after adding gesture support. - CodeScene after gesture support: `apps/mobile/src/compactGestures.ts`, `apps/mobile/src/compactGestures.test.ts`, `apps/mobile/src/SwipeSurface.tsx`, touched app/root files, and touched style module scored `10`; `apps/mobile/index.ts` returned no scorable code. diff --git a/docs/MOBILE_QUALITY_AUDIT.md b/docs/MOBILE_QUALITY_AUDIT.md new file mode 100644 index 00000000..d5c3635f --- /dev/null +++ b/docs/MOBILE_QUALITY_AUDIT.md @@ -0,0 +1,196 @@ +# Mobile Quality Audit + +Last updated: 2026-05-05 + +This audit records where the current mobile implementation falls below the desktop Tolaria product bar. The goal is not feature parity by count; it is parity in product boundaries, data semantics, interaction quality, and visual discipline for every feature we choose to ship on iPad/iPhone. + +## Current Assessment + +The mobile app has too much prototype-grade behavior. Several workflows were implemented as visible controls before their underlying product model, settings model, persistence model, and desktop-equivalent interaction semantics were in place. That creates a misleading sense of progress and makes the app feel less considered than desktop Tolaria. + +The correction strategy is: + +1. Stop adding new feature areas. +2. For each existing mobile feature, compare against the desktop implementation. +3. Either raise the mobile feature to the desktop product boundary or remove/demote it from primary UI until it can meet that bar. +4. Prefer shared/headless models over one-off mobile state. +5. Keep simulator QA and CodeScene/test gates as acceptance criteria, not afterthoughts. + +## Feature Gap Inventory + +### AI Panel And API Models + +Status: boundary corrected on 2026-05-05; remaining depth work is still open. + +Desktop reference: + +- API/local model providers are configured in Settings through `AiProviderSettings`. +- Provider config is structured: provider kind, name, base URL, model ID, API key storage mode, optional env var, test action, saved provider list. +- API keys are not typed into the chat panel. Desktop stores them through provider-secret helpers, not inside the conversation UI. +- The AI panel consumes a resolved target and shows model/agent readiness, context, message history, wikilink-aware composer, and chat/send state. + +Mobile state before correction: + +- `MobileAiPanel` mixes provider configuration, API key entry, prompt entry, and response rendering in one panel. +- Provider settings are not persisted as structured settings. +- API key storage is in component state only. +- There is no model target resolver, no settings surface, no saved provider list, no test connection flow, and no clear empty/configure state. + +Correction now in place: + +- Model/provider/API-key configuration moved out of the prompt panel into a mobile Settings surface. +- Non-secret provider config is stored in app-local settings; API keys are stored through SecureStore. +- The AI panel consumes a configured provider target only. +- If no target exists, the panel shows an empty state with a Settings action. +- Mobile scope is API models only for now; no local models, no coding agents. + +Remaining correction: + +- Add a provider test action equivalent to desktop's model test flow. +- Add explicit default-provider selection when more than one API model is configured. +- Make Settings a broader app-level surface instead of only a right-panel replacement. +- The chat composer should use note context and eventually wikilink-aware input, but configuration must never live in the prompt panel. + +### Properties And Relationships + +Status: relationship additions partially corrected on 2026-05-05; panel structure and inverse semantics remain open. + +Desktop reference: + +- Relationships are frontmatter values containing wikilinks. +- Add/remove uses canonical wikilink generation, note autocomplete, create-and-open affordance, derived inverse relationships, suggested relationship slots, and type-colored chips. +- Property editing distinguishes structural/system metadata, custom scalar properties, and relationship properties. +- Controls are specific to value type and preserve canonical frontmatter semantics. + +Mobile current state: + +- Relationship chips are visually closer than before, and new relationship additions now canonicalize selected/typed targets to wikilink refs before saving. +- Add/remove works locally but lacks create-and-open, derived inverse groups, full alias/path matching parity, and proper empty/suggested states. +- Custom property editing is basic text input with weak type semantics. +- The panel is crowded and structurally less clear than desktop. + +Required correction: + +- Store relationship values as canonical wikilinks, not loose ids. +- Share or mirror desktop relationship normalization semantics. +- Add proper empty states, suggested core relationships, derived inverse relationships, and create/open flow. +- Separate property sections visually: system, relationships, custom properties, info/history. +- Remove or demote any editable affordance that cannot write correct desktop-compatible frontmatter. + +### Wikilinks + +Desktop reference: + +- Raw editor and rich editor use shared suggestion filtering, canonical target generation, aliases, keyboard selection, and deduplication. +- AI composer also supports inline wikilinks. +- Wikilink display and navigation use desktop resolution semantics. + +Mobile current state: + +- Raw editor detects `[[` and inserts aliased links, but suggestions are basic and do not share desktop ranking, aliases, keyboard behavior, or relative path semantics. +- Rich editor wikilink insertion/navigation is still incomplete. +- Relationship add currently reuses note suggestions but does not guarantee canonical wikilink output. + +Required correction: + +- Move mobile suggestions onto shared/headless wikilink candidate logic where possible. +- Canonicalize every stored relationship and editor insertion. +- Support alias/path matching consistently across raw editor, properties, and AI composer. +- Treat basic text suggestion lists as temporary until keyboard/touch behavior matches the iOS quality bar. + +### Sidebar, Favorites, Types, Views + +Status: saved-view overclaim corrected on 2026-05-05; real persisted view support remains open. + +Desktop reference: + +- Sidebar sections are driven by real vault metadata: library filters, favorites, custom views, types, folders. +- Favorites use `_favorite` and `_favorite_index` and preserve order. +- Views are persisted view definitions with nested conditions, sorting, display properties, and editable definitions. +- Type rows use desktop type icons/colors and counts. + +Mobile current state: + +- Sidebar supports Inbox/All/Archive/Favorites/Types. Views are hidden unless persisted/loaded view definitions are explicitly supplied. +- Type styling is hardcoded rather than derived from type metadata. +- Favorites work locally but have no reorder behavior and no full desktop sidebar semantics. +- View definitions are not user-created, persisted, edited, or backed by the desktop metadata files. + +Required correction: + +- Treat static views as fixtures only; do not expose them in the app until definitions are loaded from/persisted to vault metadata. +- Use desktop-compatible type metadata for color/icon where available. +- Keep favorites if `_favorite` persistence is correct, but add ordering/reorder expectations before claiming parity. +- Add a proper saved-view model before exposing view editing. + +### Note List And Editor Shell + +Desktop reference: + +- Note list rows communicate title, snippet, metadata, type/relationship chips, search/filter context, and selection with dense but polished layout. +- Editor chrome has stable breadcrumb actions, save state, panel toggles, and note actions. +- Editor content has mature typography and reliable persistence semantics. + +Mobile current state: + +- Layout works, but text sizing/rotation/simulator orientation issues reveal insufficient visual QA. +- The raw editor is functional but not polished. +- TenTap persistence covers a useful subset but is not yet a mature markdown editor experience. +- Some actions exist as icons without the depth expected from the desktop workflow. + +Required correction: + +- QA in actual iPad landscape/portrait and iPhone sizes after every visual pass. +- Keep editor toolbar actions minimal until each action has complete behavior. +- Continue TenTap serialization hardening, but do not claim editor parity until wikilinks, selection, toolbar, keyboard, and persistence are reliable. + +### Git, Vaults, Auth + +Desktop reference: + +- Git workflows have explicit status, auth requirements, commit/push/pull, errors, and history. +- OAuth/API key/credential configuration is separated from primary writing surfaces. + +Mobile current state: + +- Git/auth boundaries are better structured than the AI panel, but the native Git transport is not implemented. +- The UI can show unavailable transport states, which is acceptable only if clearly framed as unavailable rather than fake-working. + +Required correction: + +- Keep Git sync UX honest: no action should look complete while native transport is unavailable. +- Continue native module work only after current UI quality debt is reduced. + +## Immediate Remediation Order + +1. Fix AI model configuration boundaries. + - Add mobile settings model/storage for API model providers. + - Store API keys in SecureStore. + - Refactor AI panel to consume configured providers and show empty/configure state. + +2. Rework relationship persistence and UI. + - Canonical wikilinks only. + - Proper suggested/empty states and derived inverse relationship groups. + - Remove weak custom-property editing until type semantics are clear, or make it explicitly correct. + +3. Rework saved views. + - Replace hardcoded views with loaded view definitions or label them as sample fixtures in code only. + - Do not expose view editing until persistence is real. + +4. Visual QA pass. + - Fix iPad orientation/layout issues. + - Verify note list, sidebar, editor, properties, and AI/settings across iPad landscape/portrait and iPhone. + +5. Resume feature work only when each existing surface has an explicit acceptance checklist and passes simulator QA. + +## Acceptance Bar + +A mobile feature is acceptable only when: + +- Its data model is compatible with desktop Tolaria. +- Configuration lives in settings or a dedicated setup flow, not inside primary task surfaces. +- The UI has clear empty/loading/error/saving states. +- Touch and keyboard behavior are designed, not incidental. +- It has focused tests for core model behavior. +- It has iPad simulator QA evidence. +- CodeScene remains at `10.0` for new scorable mobile files and touched files do not regress.