fix: tighten mobile quality boundaries

This commit is contained in:
lucaronin
2026-05-05 23:15:06 +02:00
parent 72ea59de0b
commit a4c3c00567
28 changed files with 1712 additions and 287 deletions

View File

@@ -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<string>
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 (
<View style={styles.properties}>
<AiToolbar onClose={onClose} />
<ScrollView contentContainerStyle={styles.aiContent}>
<Text style={styles.propertyGroupTitle}>Model</Text>
<AiModelFields
apiKey={apiKey}
baseUrl={baseUrl}
model={model}
onChangeApiKey={setApiKey}
onChangeBaseUrl={setBaseUrl}
onChangeModel={setModel}
/>
<AiPromptComposer
canSend={canSend}
<AiToolbar onClose={onClose} onOpenSettings={onOpenSettings} />
{provider ? (
<AiChatSurface
failed={failed}
isSending={isSending}
note={note}
onChangePrompt={setPrompt}
onSend={sendPrompt}
prompt={prompt}
provider={provider}
response={response}
/>
<AiResult failed={failed} response={response} />
</ScrollView>
) : (
<AiEmptyState onOpenSettings={onOpenSettings} />
)}
</View>
)
}
function AiToolbar({ onClose }: { onClose?: () => void }) {
function AiToolbar({
onClose,
onOpenSettings,
}: {
onClose?: () => void
onOpenSettings: () => void
}) {
return (
<View style={styles.toolbar}>
<Text style={styles.propertiesTitle}>AI</Text>
<View style={styles.toolbarSpacer} />
<Pressable onPress={onOpenSettings} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
<GearSix size={23} color={colors.textSoft} />
</Pressable>
{onClose ? (
<Pressable onPress={onClose} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
<CaretLeft size={23} color={colors.textSoft} />
@@ -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 (
<>
<AiInput onChangeText={onChangeBaseUrl} placeholder="Base URL" value={baseUrl} />
<AiInput onChangeText={onChangeModel} placeholder="Model" value={model} />
<AiInput onChangeText={onChangeApiKey} placeholder="API key" secureTextEntry value={apiKey} />
</>
<View style={styles.aiEmptyState}>
<Robot color={colors.iconMuted} size={26} />
<Text style={styles.aiEmptyTitle}>No API model configured</Text>
<Text style={styles.aiEmptyDescription}>Add an API model in Settings before using the AI panel.</Text>
<Pressable onPress={onOpenSettings} style={({ pressed }) => [styles.aiSettingsButton, pressed ? styles.pressed : null]}>
<GearSix color="#ffffff" size={17} />
<Text style={styles.aiSettingsButtonText}>Open Settings</Text>
</Pressable>
</View>
)
}
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 (
<>
<Text style={styles.propertyGroupTitle}>Prompt</Text>
<ScrollView contentContainerStyle={styles.aiContent}>
<View style={styles.aiContextCard}>
<Text style={styles.aiContextTitle}>{provider.name} · {provider.modelId}</Text>
<Text numberOfLines={2} style={styles.aiContextDetail}>{note.title}</Text>
</View>
{response ? <Text style={styles.aiResponse}>{response}</Text> : <Text style={styles.aiEmptyDescription}>Ask about the active note. API models run in chat mode only.</Text>}
{failed ? <Text style={styles.propertyError}>AI request failed.</Text> : null}
<TextInput
multiline
onChangeText={onChangePrompt}
@@ -123,72 +131,18 @@ function AiPromptComposer({
textAlignVertical="top"
value={prompt}
/>
<AiSendButton canSend={canSend} isSending={isSending} onSend={onSend} />
</>
)
}
function AiInput({
onChangeText,
placeholder,
secureTextEntry = false,
value,
}: {
onChangeText: (value: string) => void
placeholder: string
secureTextEntry?: boolean
value: string
}) {
return (
<TextInput
autoCapitalize="none"
autoCorrect={false}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.mutedText}
secureTextEntry={secureTextEntry}
style={styles.aiInput}
value={value}
/>
)
}
function AiSendButton({
canSend,
isSending,
onSend,
}: {
canSend: boolean
isSending: boolean
onSend: () => void
}) {
return (
<Pressable
disabled={!canSend || isSending}
onPress={onSend}
style={({ pressed }) => [
styles.aiSendButton,
!canSend || isSending ? styles.composeButtonDisabled : null,
pressed ? styles.pressed : null,
]}
>
<PaperPlaneTilt color="#ffffff" size={18} weight="fill" />
<Text style={styles.aiSendButtonText}>{isSending ? 'Sending' : 'Send'}</Text>
</Pressable>
)
}
function AiResult({
failed,
response,
}: {
failed: boolean
response: string
}) {
return (
<>
{failed ? <Text style={styles.propertyError}>AI request failed.</Text> : null}
{response ? <Text style={styles.aiResponse}>{response}</Text> : null}
</>
<Pressable
disabled={!canSend}
onPress={onSend}
style={({ pressed }) => [
styles.aiSendButton,
!canSend ? styles.composeButtonDisabled : null,
pressed ? styles.pressed : null,
]}
>
<PaperPlaneTilt color="#ffffff" size={18} weight="fill" />
<Text style={styles.aiSendButtonText}>{isSending ? 'Sending' : 'Send'}</Text>
</Pressable>
</ScrollView>
)
}

View File

@@ -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<boolean>
onClose?: () => void
onRemoveProvider: (providerId: string) => Promise<boolean>
settings: MobileAiSettings
}) {
return (
<View style={styles.properties}>
<SettingsToolbar onClose={onClose} />
<ScrollView contentContainerStyle={styles.aiSettingsContent}>
<Text style={styles.aiSettingsSectionTitle}>API models</Text>
<Text style={styles.aiSettingsDescription}>API keys are saved locally on this device and are not written to vault settings.</Text>
<ProviderList providers={settings.providers} onRemoveProvider={onRemoveProvider} />
<ProviderDraftForm failed={failed} isSaving={isSaving} onAddProvider={onAddProvider} />
</ScrollView>
</View>
)
}
function ProviderDraftForm({
failed,
isSaving,
onAddProvider,
}: {
failed: boolean
isSaving: boolean
onAddProvider: (draft: MobileAiProviderDraft) => Promise<boolean>
}) {
const [draft, setDraft] = useState<MobileAiProviderDraft>(() => 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 (
<>
<ProviderKindPicker value={draft.kind} onChange={(kind) => setDraft(initialDraft(kind))} />
<TextInput
onChangeText={(modelId) => setDraft((current) => ({ ...current, modelId }))}
placeholder={mobileAiProviderPresets[draft.kind].placeholder}
placeholderTextColor={colors.mutedText}
style={styles.aiInput}
value={draft.modelId}
/>
<TextInput
onChangeText={(apiKey) => setDraft((current) => ({ ...current, apiKey }))}
placeholder="API key"
placeholderTextColor={colors.mutedText}
secureTextEntry
style={styles.aiInput}
value={draft.apiKey}
/>
<ProviderSaveError failed={failed} />
<ProviderSaveButton disabled={isDisabled} isSaving={isSaving} onPress={submitProvider} />
</>
)
}
function ProviderSaveError({ failed }: { failed: boolean }) {
return failed ? <Text style={styles.propertyError}>Could not save AI settings.</Text> : null
}
function ProviderSaveButton({
disabled,
isSaving,
onPress,
}: {
disabled: boolean
isSaving: boolean
onPress: () => void
}) {
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.aiSettingsButton,
disabled ? styles.composeButtonDisabled : null,
pressed ? styles.pressed : null,
]}
>
<Text style={styles.aiSettingsButtonText}>{isSaving ? 'Saving' : 'Add API model'}</Text>
</Pressable>
)
}
function isProviderDraftReady(draft: MobileAiProviderDraft) {
return draft.modelId.trim().length > 0 && draft.apiKey.trim().length > 0
}
function SettingsToolbar({ onClose }: { onClose?: () => void }) {
return (
<View style={styles.toolbar}>
<Text style={styles.propertiesTitle}>Settings</Text>
<View style={styles.toolbarSpacer} />
{onClose ? (
<Pressable onPress={onClose} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
<CaretLeft size={23} color={colors.textSoft} />
</Pressable>
) : null}
</View>
)
}
function ProviderList({
onRemoveProvider,
providers,
}: {
onRemoveProvider: (providerId: string) => void
providers: MobileAiProvider[]
}) {
if (providers.length === 0) {
return <Text style={styles.aiSettingsEmpty}>No API models configured.</Text>
}
return (
<View style={styles.aiProviderList}>
{providers.map((provider) => (
<View key={provider.id} style={styles.aiProviderRow}>
<View style={styles.aiProviderText}>
<Text style={styles.aiProviderTitle}>{provider.name}</Text>
<Text numberOfLines={1} style={styles.aiProviderDetail}>{provider.modelId}</Text>
</View>
<Pressable onPress={() => onRemoveProvider(provider.id)} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
<Trash size={18} color={colors.textSoft} />
</Pressable>
</View>
))}
</View>
)
}
function ProviderKindPicker({
onChange,
value,
}: {
onChange: (value: MobileAiProviderKind) => void
value: MobileAiProviderKind
}) {
return (
<View style={styles.aiProviderKindRow}>
{providerKinds.map((kind) => (
<Pressable
key={kind}
onPress={() => onChange(kind)}
style={({ pressed }) => [
styles.aiProviderKindChip,
value === kind ? styles.aiProviderKindChipSelected : null,
pressed ? styles.pressed : null,
]}
>
<Text style={[styles.aiProviderKindText, value === kind ? styles.aiProviderKindTextSelected : null]}>{mobileAiProviderPresets[kind].name}</Text>
</Pressable>
))}
</View>
)
}
function initialDraft(kind: MobileAiProviderKind): MobileAiProviderDraft {
return {
apiKey: '',
kind,
modelId: '',
name: mobileAiProviderPresets[kind].name,
}
}

View File

@@ -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<Record<string, 'raw' | 'rich'>>({})
const [rightPanel, setRightPanel] = useState<'ai' | 'properties'>('properties')
const [rightPanel, setRightPanel] = useState<'ai' | 'properties' | 'settings'>('properties')
const [sidebarSelection, setSidebarSelection] = useState<MobileSidebarSelection>(defaultMobileSidebarSelection)
const [saveStateByNoteId, setSaveStateByNoteId] = useState<Record<string, MobileEditorSaveState>>({})
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 <MobileAppShell {...appShellProps} />
}
type MobileRightPanel = 'ai' | 'properties' | 'settings'
type MobileAppShellProps = {
activeVault: MobileVaultMetadata
aiFailed: boolean
aiSettings: MobileAiSettings
allNotes: MobileNote[]
compactNavigation: ReturnType<typeof createCompactNavigationState>
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<typeof createMobileSidebarSections>
sidebarSelection: MobileSidebarSelection
onAddAiProvider: (draft: MobileAiProviderDraft) => Promise<boolean>
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<boolean>
onRetryRuntimeLoad: () => void
onSelectNote: (note: MobileNote) => void
onSelectNoteId: (noteId: string) => void
onSelectSidebar: (selection: MobileSidebarSelection) => void
onSendAiPrompt: (prompt: string, provider: MobileAiProvider) => Promise<string>
onSubmitRemoteSetup: () => void
onToggleArchive: () => void
onToggleEditorMode: () => void
onToggleFavorite: () => void
}
function MobileAppShell(props: MobileAppShellProps) {
return (
<SafeAreaProvider>
<SafeAreaView style={styles.safeArea}>
{isTablet ? (
<View style={styles.tabletShell}>
<SidebarPanel
activeVault={activeVaultMetadata}
activeSelection={sidebarSelection}
sections={sidebarSections}
onOpenRemoteSetup={remoteSetupFlow.open}
onSelect={selectSidebar}
/>
<NoteListPanel
gitSyncPlan={gitSyncFlow.gitSyncPlan}
listTitle={listTitle}
notes={visibleNotes}
selectedNoteId={compactNavigation.selectedNoteId}
createNoteFailed={createFlow.failed}
isCreatingNote={createFlow.isCreating}
runtimeLoadFailed={runtimeLoader.failed}
onCreateNote={createFlow.create}
onGitSyncAction={gitSyncFlow.runPrimaryAction}
onRetryRuntimeLoad={runtimeLoader.retry}
onSelectNote={selectNote}
/>
<EditorPanel
editorMode={selectedEditorMode}
notes={availableNotes}
note={selectedNote}
saveState={selectedSaveState}
onDeleteNote={deleteFlow.canDelete ? deleteFlow.deleteSelectedNote : undefined}
onDraftChange={saveDraft}
onOpenAi={() => setRightPanel('ai')}
onOpenProperties={() => setRightPanel('properties')}
onRawMarkdownChange={saveRawMarkdown}
onToggleArchive={toggleSelectedArchive}
onToggleEditorMode={toggleEditorMode}
onToggleFavorite={toggleSelectedFavorite}
/>
{showsProperties && rightPanel === 'properties' ? (
<MobilePropertiesPanel
failed={propertiesFlow.failed}
isSaving={propertiesFlow.isSaving}
notes={availableNotes}
note={selectedNote}
onChangeProperties={propertiesFlow.saveProperties}
onOpenNote={selectNoteId}
/>
) : null}
{showsProperties && rightPanel === 'ai' ? <MobileAiPanel note={selectedNote} /> : null}
</View>
) : (
<CompactShell
activePanel={compactNavigation.panel}
activeVault={activeVaultMetadata}
activeSidebarSelection={sidebarSelection}
allNotes={availableNotes}
editorMode={selectedEditorMode}
note={selectedNote}
gitSyncPlan={gitSyncFlow.gitSyncPlan}
listTitle={listTitle}
notes={visibleNotes}
saveState={selectedSaveState}
selectedNoteId={compactNavigation.selectedNoteId}
sidebarSections={sidebarSections}
onNavigate={(event) => 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 ? (
<MobileVaultRemotePrompt
failed={remoteSetupFlow.failed}
hasGitHubOAuthClientId={gitHubOAuthClientIdState.state === 'configured'}
isSaving={remoteSetupFlow.isSaving}
onCancel={remoteSetupFlow.cancel}
onChangeRemoteUrl={remoteSetupFlow.setRemoteUrl}
onSubmit={remoteSetupFlow.submit}
remoteUrl={remoteSetupFlow.remoteUrl}
/>
) : null}
{props.isTablet ? <TabletShell {...props} /> : <CompactAppShell {...props} />}
<RemoteSetupPrompt {...props} />
</SafeAreaView>
</SafeAreaProvider>
)
}
function TabletShell(props: MobileAppShellProps) {
return (
<View style={styles.tabletShell}>
<SidebarPanel
activeVault={props.activeVault}
activeSelection={props.sidebarSelection}
sections={props.sidebarSections}
onOpenRemoteSetup={props.onOpenRemoteSetup}
onSelect={props.onSelectSidebar}
/>
<NoteListPanel
gitSyncPlan={props.gitSyncPlan}
listTitle={props.listTitle}
notes={props.notes}
selectedNoteId={props.selectedNoteId}
createNoteFailed={props.createNoteFailed}
isCreatingNote={props.isCreatingNote}
runtimeLoadFailed={props.runtimeLoadFailed}
onCreateNote={props.onCreateNote}
onGitSyncAction={props.onGitSyncAction}
onRetryRuntimeLoad={props.onRetryRuntimeLoad}
onSelectNote={props.onSelectNote}
/>
<EditorPanel
editorMode={props.editorMode}
notes={props.allNotes}
note={props.selectedNote}
saveState={props.saveState}
onDeleteNote={props.onDeleteNote}
onDraftChange={props.onDraftChange}
onOpenAi={props.onOpenAi}
onOpenProperties={props.onOpenProperties}
onRawMarkdownChange={props.onRawMarkdownChange}
onToggleArchive={props.onToggleArchive}
onToggleEditorMode={props.onToggleEditorMode}
onToggleFavorite={props.onToggleFavorite}
/>
<TabletRightPanel {...props} />
</View>
)
}
function TabletRightPanel(props: MobileAppShellProps) {
if (!props.showsProperties) return null
if (props.rightPanel === 'ai') {
return (
<MobileAiPanel
note={props.selectedNote}
provider={props.selectedProvider}
onOpenSettings={props.onOpenAiSettings}
onSendPrompt={props.onSendAiPrompt}
/>
)
}
if (props.rightPanel === 'settings') {
return (
<MobileAiSettingsPanel
failed={props.aiFailed}
isSaving={props.isSavingAiSettings}
settings={props.aiSettings}
onAddProvider={props.onAddAiProvider}
onRemoveProvider={props.onRemoveAiProvider}
onClose={props.onOpenAi}
/>
)
}
return (
<MobilePropertiesPanel
failed={props.propertiesFailed}
isSaving={props.isSavingProperties}
notes={props.allNotes}
note={props.selectedNote}
onChangeProperties={props.onChangeProperties}
onOpenNote={props.onSelectNoteId}
/>
)
}
function CompactAppShell(props: MobileAppShellProps) {
return (
<CompactShell
activePanel={props.compactNavigation.panel}
activeVault={props.activeVault}
activeSidebarSelection={props.sidebarSelection}
allNotes={props.allNotes}
editorMode={props.editorMode}
note={props.selectedNote}
gitSyncPlan={props.gitSyncPlan}
listTitle={props.listTitle}
notes={props.notes}
saveState={props.saveState}
selectedNoteId={props.selectedNoteId}
sidebarSections={props.sidebarSections}
onNavigate={props.onNavigate}
onDeleteNote={props.onDeleteNote}
onDraftChange={props.onDraftChange}
onRawMarkdownChange={props.onRawMarkdownChange}
onSelectSidebar={props.onSelectSidebar}
onToggleArchive={props.onToggleArchive}
onToggleEditorMode={props.onToggleEditorMode}
onToggleFavorite={props.onToggleFavorite}
createNoteFailed={props.createNoteFailed}
isCreatingNote={props.isCreatingNote}
runtimeLoadFailed={props.runtimeLoadFailed}
onCreateNote={props.onCreateNote}
onGitSyncAction={props.onGitSyncAction}
onRetryRuntimeLoad={props.onRetryRuntimeLoad}
onSelectNote={props.onSelectNote}
propertiesFailed={props.propertiesFailed}
isSavingProperties={props.isSavingProperties}
onChangeProperties={props.onChangeProperties}
onOpenRemoteSetup={props.onOpenRemoteSetup}
/>
)
}
function RemoteSetupPrompt(props: MobileAppShellProps) {
if (!props.isRemoteSetupOpen) return null
return (
<MobileVaultRemotePrompt
failed={props.remoteSetupFailed}
hasGitHubOAuthClientId={props.gitHubOAuthClientIdState === 'configured'}
isSaving={props.remoteSetupIsSaving}
onCancel={props.onCancelRemoteSetup}
onChangeRemoteUrl={props.onChangeRemoteUrl}
onSubmit={props.onSubmitRemoteSetup}
remoteUrl={props.remoteSetupUrl}
/>
)
}
type CompactShellProps = {
activePanel: CompactPanel
activeVault: MobileVaultMetadata

View File

@@ -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) => (
<RelationshipChip
key={target}
note={findRelationshipNote({ notes, target })}
onRemove={writableTargets.includes(target) && onChangeTargets ? () => 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) => (
<Pressable
key={suggestion.id}
onPress={() => onAddTarget(suggestion.id)}
onPress={() => onAddTarget(suggestion.title)}
style={({ pressed }) => [styles.relationshipSuggestion, pressed ? styles.pressed : null]}
>
<Text style={styles.relationshipSuggestionText}>{suggestion.title}</Text>
@@ -334,7 +347,7 @@ function RelationshipChip({
target: string
}) {
const appearance = mobileRelationshipAppearance(note?.type)
const chip = <Text style={styles.relationshipChipText}>{note?.title ?? target}</Text>
const chip = <Text style={styles.relationshipChipText}>{note?.title ?? mobileRelationshipDisplayLabel(target)}</Text>
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())
}

View File

@@ -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({

View File

@@ -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}`,

View File

@@ -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<string, string>()
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()
})
})

View File

@@ -0,0 +1,29 @@
export type MobileAiProviderSecretStore = {
deleteItemAsync: (key: string) => Promise<void>
getItemAsync: (key: string) => Promise<string | null>
setItemAsync: (key: string, value: string) => Promise<void>
}
export type MobileAiProviderSecretStorage = {
loadApiKey: (providerId: string) => Promise<string | null>
removeApiKey: (providerId: string) => Promise<void>
saveApiKey: (providerId: string, apiKey: string) => Promise<void>
}
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(':')
}

View File

@@ -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',
}
}

View File

@@ -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<MobileAiProviderKind, { baseUrl: string; name: string; placeholder: string }> = {
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'
}

View File

@@ -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<string, string>()
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' })
})
})

View File

@@ -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<MobileAiSettingsFileInfo>
makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise<void>
readAsStringAsync: (uri: string) => Promise<string>
writeAsStringAsync: (uri: string, content: string) => Promise<void>
}
export type MobileAiSettingsStorage = {
load: () => Promise<MobileAiSettings>
save: (settings: MobileAiSettings) => Promise<void>
}
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`
}

View File

@@ -0,0 +1,6 @@
import * as SecureStore from 'expo-secure-store'
import { createMobileAiProviderSecretStorage } from './mobileAiProviderSecretStorage'
export function createNativeMobileAiProviderSecretStorage() {
return createMobileAiProviderSecretStorage(SecureStore)
}

View File

@@ -0,0 +1,6 @@
import * as FileSystem from 'expo-file-system/legacy'
import { createMobileAiSettingsStorage } from './mobileAiSettingsStorage'
export function createNativeMobileAiSettingsStorage() {
return createMobileAiSettingsStorage(FileSystem)
}

View File

@@ -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,
}
}

View File

@@ -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<MobileNote, 'id' | 'title'>) {
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<string, string>()
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<MobileNote, 'id' | 'title'>) {
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
}

View File

@@ -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', () => {

View File

@@ -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')
}

View File

@@ -29,7 +29,7 @@ export type MobileViewDefinition = {
name: string
}
export const mobileViewDefinitions: MobileViewDefinition[] = [
export const mobileFixtureViewDefinitions: MobileViewDefinition[] = [
{
color: '#3f7f5f',
filters: {

View File

@@ -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)}`
}

View File

@@ -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,

View File

@@ -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',
},
})

View File

@@ -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,
},
})

View File

@@ -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,
},
})

View File

@@ -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,

View File

@@ -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<MobileAiSettings>(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<boolean> {
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)
}
}

View File

@@ -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.

View File

@@ -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.