fix: tighten mobile quality boundaries
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
197
apps/mobile/src/MobileAiSettingsPanel.tsx
Normal file
197
apps/mobile/src/MobileAiSettingsPanel.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
25
apps/mobile/src/mobileAiProviderSecretStorage.test.ts
Normal file
25
apps/mobile/src/mobileAiProviderSecretStorage.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
29
apps/mobile/src/mobileAiProviderSecretStorage.ts
Normal file
29
apps/mobile/src/mobileAiProviderSecretStorage.ts
Normal 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(':')
|
||||
}
|
||||
66
apps/mobile/src/mobileAiSettings.test.ts
Normal file
66
apps/mobile/src/mobileAiSettings.test.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
166
apps/mobile/src/mobileAiSettings.ts
Normal file
166
apps/mobile/src/mobileAiSettings.ts
Normal 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'
|
||||
}
|
||||
34
apps/mobile/src/mobileAiSettingsStorage.test.ts
Normal file
34
apps/mobile/src/mobileAiSettingsStorage.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
82
apps/mobile/src/mobileAiSettingsStorage.ts
Normal file
82
apps/mobile/src/mobileAiSettingsStorage.ts
Normal 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`
|
||||
}
|
||||
6
apps/mobile/src/mobileNativeAiProviderSecretStorage.ts
Normal file
6
apps/mobile/src/mobileNativeAiProviderSecretStorage.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as SecureStore from 'expo-secure-store'
|
||||
import { createMobileAiProviderSecretStorage } from './mobileAiProviderSecretStorage'
|
||||
|
||||
export function createNativeMobileAiProviderSecretStorage() {
|
||||
return createMobileAiProviderSecretStorage(SecureStore)
|
||||
}
|
||||
6
apps/mobile/src/mobileNativeAiSettingsStorage.ts
Normal file
6
apps/mobile/src/mobileNativeAiSettingsStorage.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as FileSystem from 'expo-file-system/legacy'
|
||||
import { createMobileAiSettingsStorage } from './mobileAiSettingsStorage'
|
||||
|
||||
export function createNativeMobileAiSettingsStorage() {
|
||||
return createMobileAiSettingsStorage(FileSystem)
|
||||
}
|
||||
67
apps/mobile/src/mobileRelationshipRefs.test.ts
Normal file
67
apps/mobile/src/mobileRelationshipRefs.test.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
115
apps/mobile/src/mobileRelationshipRefs.ts
Normal file
115
apps/mobile/src/mobileRelationshipRefs.ts
Normal 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
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export type MobileViewDefinition = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export const mobileViewDefinitions: MobileViewDefinition[] = [
|
||||
export const mobileFixtureViewDefinitions: MobileViewDefinition[] = [
|
||||
{
|
||||
color: '#3f7f5f',
|
||||
filters: {
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
55
apps/mobile/src/styles/aiControlStyles.ts
Normal file
55
apps/mobile/src/styles/aiControlStyles.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
31
apps/mobile/src/styles/aiProviderKindStyles.ts
Normal file
31
apps/mobile/src/styles/aiProviderKindStyles.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
54
apps/mobile/src/styles/aiSettingsStyles.ts
Normal file
54
apps/mobile/src/styles/aiSettingsStyles.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
122
apps/mobile/src/useMobileAiSettingsFlow.ts
Normal file
122
apps/mobile/src/useMobileAiSettingsFlow.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
196
docs/MOBILE_QUALITY_AUDIT.md
Normal file
196
docs/MOBILE_QUALITY_AUDIT.md
Normal 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.
|
||||
Reference in New Issue
Block a user