feat: add mobile workflow affordances

This commit is contained in:
lucaronin
2026-05-05 20:27:12 +02:00
parent e6e616267b
commit 72ea59de0b
34 changed files with 1519 additions and 53 deletions

View File

@@ -0,0 +1,194 @@
import { CaretLeft, PaperPlaneTilt } 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 { styles } from './styles'
import { colors } from './theme'
export function MobileAiPanel({
note,
onClose,
}: {
note: MobileNote
onClose?: () => void
}) {
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 = () => {
setFailed(false)
setIsSending(true)
void sendMobileAiRequest({ apiKey, baseUrl, model, note, prompt })
.then(setResponse)
.catch(() => setFailed(true))
.finally(() => setIsSending(false))
}
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}
isSending={isSending}
note={note}
onChangePrompt={setPrompt}
onSend={sendPrompt}
prompt={prompt}
/>
<AiResult failed={failed} response={response} />
</ScrollView>
</View>
)
}
function AiToolbar({ onClose }: { onClose?: () => void }) {
return (
<View style={styles.toolbar}>
<Text style={styles.propertiesTitle}>AI</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 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
}) {
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} />
</>
)
}
function AiPromptComposer({
canSend,
isSending,
note,
onChangePrompt,
onSend,
prompt,
}: {
canSend: boolean
isSending: boolean
note: MobileNote
onChangePrompt: (value: string) => void
onSend: () => void
prompt: string
}) {
return (
<>
<Text style={styles.propertyGroupTitle}>Prompt</Text>
<TextInput
multiline
onChangeText={onChangePrompt}
placeholder={`Ask about ${note.title}`}
placeholderTextColor={colors.mutedText}
style={styles.aiPrompt}
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}
</>
)
}

View File

@@ -15,6 +15,8 @@ import {
List,
MagnifyingGlass,
PencilSimple,
Robot,
Star,
Trash,
} from 'phosphor-react-native'
import { MobileNote, notes as fallbackNotes } from './demoData'
@@ -48,6 +50,7 @@ import { styles } from './styles'
import { colors } from './theme'
import { MobileEditorBreadcrumb } from './MobileEditorBreadcrumb'
import { MobilePropertiesPanel } from './MobilePropertiesPanel'
import { MobileAiPanel } from './MobileAiPanel'
import { MobileVaultManagementCard } from './MobileVaultManagementCard'
import { MobileVaultRemotePrompt } from './MobileVaultRemotePrompt'
import { useMobileNoteCreateFlow } from './useMobileNoteCreateFlow'
@@ -78,6 +81,7 @@ import {
mobileSidebarTitle,
type MobileSidebarSelection,
} from './mobileSidebarNavigation'
import { mobileTypeAppearance } from './mobileTypeAppearance'
export function MobileApp() {
const { width } = useWindowDimensions()
@@ -92,6 +96,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 [sidebarSelection, setSidebarSelection] = useState<MobileSidebarSelection>(defaultMobileSidebarSelection)
const [saveStateByNoteId, setSaveStateByNoteId] = useState<Record<string, MobileEditorSaveState>>({})
const sidebarSections = useMemo(() => createMobileSidebarSections(availableNotes), [availableNotes])
@@ -215,6 +220,12 @@ export function MobileApp() {
setAvailableNotes((notes) => notes.map((note) => (note.id === selectedNote.id ? { ...note, archived } : note)))
propertiesFlow.saveProperties({ archived })
}, [propertiesFlow, selectedNote.archived, selectedNote.id])
const toggleSelectedFavorite = useCallback(() => {
const favorite = !selectedNote.favorite
const favoriteIndex = favorite ? availableNotes.filter((note) => note.favorite).length : null
setAvailableNotes((notes) => notes.map((note) => (note.id === selectedNote.id ? { ...note, favorite, favoriteIndex } : note)))
propertiesFlow.saveProperties({ favorite, favoriteIndex })
}, [availableNotes, propertiesFlow, selectedNote.favorite, selectedNote.id])
return (
<SafeAreaProvider>
@@ -243,15 +254,19 @@ export function MobileApp() {
/>
<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 ? (
{showsProperties && rightPanel === 'properties' ? (
<MobilePropertiesPanel
failed={propertiesFlow.failed}
isSaving={propertiesFlow.isSaving}
@@ -261,6 +276,7 @@ export function MobileApp() {
onOpenNote={selectNoteId}
/>
) : null}
{showsProperties && rightPanel === 'ai' ? <MobileAiPanel note={selectedNote} /> : null}
</View>
) : (
<CompactShell
@@ -283,6 +299,7 @@ export function MobileApp() {
onSelectSidebar={selectSidebar}
onToggleArchive={toggleSelectedArchive}
onToggleEditorMode={toggleEditorMode}
onToggleFavorite={toggleSelectedFavorite}
createNoteFailed={createFlow.failed}
isCreatingNote={createFlow.isCreating}
runtimeLoadFailed={runtimeLoader.failed}
@@ -330,6 +347,7 @@ type CompactShellProps = {
onNavigate: (event: CompactNavigationEvent) => void
onDeleteNote?: () => void
onDraftChange: (draft: MobileEditorDraft) => void
onOpenAi?: () => void
onRawMarkdownChange: (markdown: string) => void
onCreateNote: () => void
onGitSyncAction: () => void
@@ -340,6 +358,7 @@ type CompactShellProps = {
onSelectSidebar: (selection: MobileSidebarSelection) => void
onToggleArchive: () => void
onToggleEditorMode: () => void
onToggleFavorite: () => void
propertiesFailed: boolean
isSavingProperties: boolean
selectedNoteId: string
@@ -384,15 +403,18 @@ function CompactEditorPanel(props: CompactShellProps) {
<SwipeSurface panel="note" onNavigate={props.onNavigate}>
<EditorPanel
editorMode={props.editorMode}
notes={props.allNotes}
note={props.note}
saveState={props.saveState}
onDeleteNote={props.onDeleteNote}
onDraftChange={props.onDraftChange}
onOpenAi={props.onOpenAi}
onRawMarkdownChange={props.onRawMarkdownChange}
onBack={() => props.onNavigate({ type: 'backToList' })}
onOpenProperties={() => props.onNavigate({ type: 'openProperties' })}
onToggleArchive={props.onToggleArchive}
onToggleEditorMode={props.onToggleEditorMode}
onToggleFavorite={props.onToggleFavorite}
/>
</SwipeSurface>
)
@@ -501,7 +523,9 @@ function SidebarPanel({
}
function sidebarItemKey(selection: MobileSidebarSelection) {
return selection.kind === 'type' ? `type:${selection.type}` : `library:${selection.id}`
if (selection.kind === 'type') return `type:${selection.type}`
if (selection.kind === 'view') return `view:${selection.id}`
return `library:${selection.id}`
}
function NoteListPanel({
@@ -556,6 +580,7 @@ function NoteListPanel({
>
<View style={styles.noteRowHeader}>
<Text style={styles.noteTitle}>{item.title}</Text>
{item.favorite ? <Star color={colors.primary} size={16} weight="fill" /> : null}
<NamedIcon name={item.icon as IconName} size={18} color={colors.primary} />
</View>
<Text numberOfLines={2} style={styles.noteSnippet}>{item.snippet}</Text>
@@ -564,6 +589,7 @@ function NoteListPanel({
<Text style={styles.noteMeta}>Created {item.date}</Text>
</View>
<View style={styles.tagRow}>
<TypeChip type={item.type} />
{item.tags.slice(0, 2).map((tag) => <Tag key={tag} label={tag} />)}
</View>
</Pressable>
@@ -613,26 +639,32 @@ function selectLoadedNote(
function EditorPanel({
editorMode,
notes,
note,
saveState,
onDeleteNote,
onDraftChange,
onOpenAi,
onBack,
onOpenProperties,
onRawMarkdownChange,
onToggleArchive,
onToggleEditorMode,
onToggleFavorite,
}: {
editorMode: 'raw' | 'rich'
notes: MobileNote[]
note: MobileNote
saveState?: MobileEditorSaveState
onDeleteNote?: () => void
onDraftChange?: (draft: MobileEditorDraft) => void
onOpenAi?: () => void
onBack?: () => void
onOpenProperties?: () => void
onRawMarkdownChange: (markdown: string) => void
onToggleArchive: () => void
onToggleEditorMode: () => void
onToggleFavorite: () => void
}) {
return (
<View style={styles.editor}>
@@ -643,14 +675,16 @@ function EditorPanel({
note={note}
saveState={saveState ?? idleMobileEditorSaveState}
onToggleArchive={onToggleArchive}
onToggleFavorite={onToggleFavorite}
onToggleRawMode={onToggleEditorMode}
/>
{onOpenProperties ? <IconButton icon={<Info size={23} color={colors.textSoft} />} onPress={onOpenProperties} /> : null}
{onOpenAi ? <IconButton icon={<Robot size={23} color={colors.textSoft} />} onPress={onOpenAi} /> : null}
{onDeleteNote ? <IconButton icon={<Trash size={23} color={colors.textSoft} />} onPress={onDeleteNote} /> : null}
<IconButton icon={<DotsThreeVertical size={23} color={colors.textSoft} />} />
</Toolbar>
{editorMode === 'raw'
? <MobileRawEditor key={note.id} note={note} onRawMarkdownChange={onRawMarkdownChange} />
? <MobileRawEditor key={note.id} notes={notes} note={note} onRawMarkdownChange={onRawMarkdownChange} />
: <MobileEditorAdapter note={note} onDraftChange={onDraftChange} />}
</View>
)
@@ -671,3 +705,21 @@ function IconButton({ icon, onPress }: { icon: React.ReactNode; onPress?: () =>
function Tag({ label }: { label: string }) {
return <Text style={styles.tag}>{label}</Text>
}
function TypeChip({ type }: { type: string }) {
const appearance = mobileTypeAppearance(type)
return (
<Text
style={[
styles.tag,
{
backgroundColor: appearance.backgroundColor,
borderColor: appearance.borderColor,
color: appearance.color,
},
]}
>
{type}
</Text>
)
}

View File

@@ -1,4 +1,4 @@
import { Archive, Code, PencilSimpleLine, Tray } from 'phosphor-react-native'
import { Archive, Code, PencilSimpleLine, Star, Tray } from 'phosphor-react-native'
import type { ReactNode } from 'react'
import { Pressable, Text, View } from 'react-native'
import type { MobileNote } from './demoData'
@@ -10,12 +10,14 @@ export function MobileEditorBreadcrumb({
isRawMode,
note,
onToggleArchive,
onToggleFavorite,
onToggleRawMode,
saveState,
}: {
isRawMode: boolean
note: MobileNote
onToggleArchive: () => void
onToggleFavorite: () => void
onToggleRawMode: () => void
saveState: MobileEditorSaveState
}) {
@@ -31,6 +33,9 @@ export function MobileEditorBreadcrumb({
<BreadcrumbButton label={isRawMode ? 'Rich editor' : 'Raw editor'} onPress={onToggleRawMode}>
<RawIcon color={isRawMode ? colors.primary : colors.textSoft} size={18} />
</BreadcrumbButton>
<BreadcrumbButton label={note.favorite ? 'Remove favorite' : 'Add favorite'} onPress={onToggleFavorite}>
<Star color={note.favorite ? colors.primary : colors.textSoft} size={18} weight={note.favorite ? 'fill' : 'regular'} />
</BreadcrumbButton>
<BreadcrumbButton label={note.archived ? 'Move to inbox' : 'Archive'} onPress={onToggleArchive}>
<ArchiveIcon color={colors.textSoft} size={18} />
</BreadcrumbButton>

View File

@@ -1,10 +1,12 @@
import { CaretLeft } from 'phosphor-react-native'
import { CaretLeft, Plus, X } from 'phosphor-react-native'
import { useState } from 'react'
import { Pressable, ScrollView, Text, View } from 'react-native'
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'
import type { MobileNote } from './demoData'
import { MobileEditablePropertyPickers } from './MobileEditablePropertyPickers'
import type { MobileNotePropertyPatch } from './mobileNoteProperties'
import { nextMobilePropertyPicker, type MobilePropertyPickerKey } from './mobilePropertyPicker'
import { mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
import { mobileRelationshipAppearance } from './mobileTypeAppearance'
import { styles } from './styles'
import { colors } from './theme'
@@ -42,9 +44,46 @@ export function MobilePropertiesPanel({
onChangeProperties={onChangeProperties}
onSelectPicker={selectPicker}
/>
<RelationshipGroup label="Belongs to" notes={notes} targets={note.belongsTo} onOpenNote={onOpenNote} />
<RelationshipGroup label="Related to" notes={notes} targets={[...note.relatedTo, ...note.outgoingLinks]} onOpenNote={onOpenNote} />
<RelationshipGroup label="Has" notes={notes} targets={note.has} onOpenNote={onOpenNote} />
<RelationshipGroup
label="Belongs to"
notes={notes}
targets={note.belongsTo}
onChangeTargets={(belongsTo) => onChangeProperties?.({ belongsTo })}
onOpenNote={onOpenNote}
/>
<RelationshipGroup
label="Related to"
notes={notes}
targets={[...note.relatedTo, ...note.outgoingLinks]}
writableTargets={note.relatedTo}
onChangeTargets={(relatedTo) => onChangeProperties?.({ relatedTo })}
onOpenNote={onOpenNote}
/>
<RelationshipGroup
label="Has"
notes={notes}
targets={note.has}
onChangeTargets={(has) => onChangeProperties?.({ has })}
onOpenNote={onOpenNote}
/>
{Object.entries(note.relationships).map(([key, targets]) => (
<RelationshipGroup
key={key}
label={formatRelationshipLabel(key)}
notes={notes}
targets={targets}
onChangeTargets={(nextTargets) => onChangeProperties?.({
relationships: { ...note.relationships, [key]: nextTargets },
removedRelationshipKeys: nextTargets.length === 0 ? [key] : undefined,
})}
onOpenNote={onOpenNote}
/>
))}
<AddRelationshipGroup
note={note}
onAdd={(key) => onChangeProperties?.({ relationships: { ...note.relationships, [key]: [] } })}
/>
<CustomProperties note={note} onChangeProperties={onChangeProperties} />
<BacklinkGroup backlinks={note.backlinks} onOpenNote={onOpenNote} />
<PropertyRow label="Words" value={String(note.words)} />
<PropertyRow label="Modified" value={note.modified} />
@@ -59,32 +98,198 @@ export function MobilePropertiesPanel({
function RelationshipGroup({
label,
notes,
onChangeTargets,
onOpenNote,
targets,
writableTargets = targets,
}: {
label: string
notes: MobileNote[]
onChangeTargets?: (targets: string[]) => void
onOpenNote?: (noteId: string) => void
targets: string[]
writableTargets?: string[]
}) {
const [query, setQuery] = useState('')
const [isAdding, setIsAdding] = useState(false)
const uniqueTargets = [...new Set(targets)]
if (uniqueTargets.length === 0) {
return null
const suggestions = isAdding ? mobileNoteSuggestions({ notes, query }) : []
const addTarget = (target: string) => {
onChangeTargets?.([...writableTargets, target])
setQuery('')
setIsAdding(false)
}
return (
<View style={styles.relationshipGroup}>
<Text style={styles.propertyGroupTitle}>{label}</Text>
<RelationshipHeader canAdd={Boolean(onChangeTargets)} label={label} onAdd={() => setIsAdding(true)} />
<View style={styles.relationshipChipRow}>
{uniqueTargets.map((target) => (
<RelationshipChip
key={target}
note={findRelationshipNote({ notes, target })}
onRemove={writableTargets.includes(target) && onChangeTargets ? () => onChangeTargets(writableTargets.filter((item) => item !== target)) : undefined}
target={target}
onOpenNote={onOpenNote}
/>
))}
</View>
{isAdding ? <RelationshipAddBox onAddTarget={addTarget} onChangeQuery={setQuery} query={query} suggestions={suggestions} /> : null}
</View>
)
}
function RelationshipHeader({
canAdd,
label,
onAdd,
}: {
canAdd: boolean
label: string
onAdd: () => void
}) {
return (
<View style={styles.relationshipHeader}>
<Text style={styles.propertyGroupTitle}>{label}</Text>
{canAdd ? (
<Pressable onPress={onAdd} style={({ pressed }) => [styles.relationshipAddButton, pressed ? styles.pressed : null]}>
<Plus color={colors.textSoft} size={14} />
</Pressable>
) : null}
</View>
)
}
function RelationshipAddBox({
onAddTarget,
onChangeQuery,
query,
suggestions,
}: {
onAddTarget: (target: string) => void
onChangeQuery: (query: string) => void
query: string
suggestions: MobileNote[]
}) {
return (
<View style={styles.relationshipAddBox}>
<TextInput
autoCapitalize="none"
autoCorrect={false}
onChangeText={onChangeQuery}
onSubmitEditing={() => {
if (query.trim().length > 0) {
onAddTarget(query.trim())
}
}}
placeholder="Add note"
placeholderTextColor={colors.mutedText}
style={styles.relationshipInput}
value={query}
/>
{suggestions.map((suggestion) => (
<Pressable
key={suggestion.id}
onPress={() => onAddTarget(suggestion.id)}
style={({ pressed }) => [styles.relationshipSuggestion, pressed ? styles.pressed : null]}
>
<Text style={styles.relationshipSuggestionText}>{suggestion.title}</Text>
</Pressable>
))}
</View>
)
}
function AddRelationshipGroup({
note,
onAdd,
}: {
note: MobileNote
onAdd: (key: string) => void
}) {
const [name, setName] = useState('')
return (
<View style={styles.relationshipGroup}>
<TextInput
autoCapitalize="none"
autoCorrect={false}
onChangeText={setName}
onSubmitEditing={() => {
const key = relationshipKeyFromLabel(name)
if (key && !note.relationships[key]) {
onAdd(key)
setName('')
}
}}
placeholder="+ Add relationship"
placeholderTextColor={colors.mutedText}
style={styles.relationshipInput}
value={name}
/>
</View>
)
}
function CustomProperties({
note,
onChangeProperties,
}: {
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
}) {
const [draftKey, setDraftKey] = useState('')
const [draftValue, setDraftValue] = useState('')
const entries = Object.entries(note.customProperties)
return (
<View style={styles.relationshipGroup}>
<Text style={styles.propertyGroupTitle}>Custom properties</Text>
{entries.map(([key, value]) => (
<View key={key} style={styles.customPropertyRow}>
<Text style={styles.customPropertyKey}>{formatRelationshipLabel(key)}</Text>
<TextInput
onChangeText={(nextValue) => onChangeProperties?.({ customProperties: { ...note.customProperties, [key]: nextValue } })}
style={styles.customPropertyValue}
value={value}
/>
<Pressable
onPress={() => {
const rest = customPropertiesWithoutKey({ key, properties: note.customProperties })
onChangeProperties?.({ customProperties: rest, removedCustomPropertyKeys: [key] })
}}
style={({ pressed }) => [styles.relationshipRemoveButton, pressed ? styles.pressed : null]}
>
<X color={colors.mutedText} size={13} />
</Pressable>
</View>
))}
<View style={styles.customPropertyAddRow}>
<TextInput
autoCapitalize="none"
autoCorrect={false}
onChangeText={setDraftKey}
placeholder="Property"
placeholderTextColor={colors.mutedText}
style={styles.customPropertyAddInput}
value={draftKey}
/>
<TextInput
onChangeText={setDraftValue}
onSubmitEditing={() => {
const key = relationshipKeyFromLabel(draftKey)
if (key && draftValue.trim().length > 0) {
onChangeProperties?.({ customProperties: { ...note.customProperties, [key]: draftValue.trim() } })
setDraftKey('')
setDraftValue('')
}
}}
placeholder="Value"
placeholderTextColor={colors.mutedText}
style={styles.customPropertyAddInput}
value={draftValue}
/>
</View>
</View>
)
}
@@ -120,23 +325,41 @@ function BacklinkGroup({
function RelationshipChip({
note,
onOpenNote,
onRemove,
target,
}: {
note?: { id: string; title: string }
note?: { id: string; title: string; type?: string }
onOpenNote?: (noteId: string) => void
onRemove?: () => void
target: string
}) {
const appearance = mobileRelationshipAppearance(note?.type)
const chip = <Text style={styles.relationshipChipText}>{note?.title ?? target}</Text>
const content = (
<>
{chip}
{onRemove ? (
<Pressable onPress={onRemove} style={styles.relationshipRemoveButton}>
<X color={appearance.color} size={12} />
</Pressable>
) : null}
</>
)
return note && onOpenNote ? (
<Pressable
onPress={() => onOpenNote(note.id)}
style={({ pressed }) => [styles.relationshipChip, pressed ? styles.pressed : null]}
style={({ pressed }) => [
styles.relationshipChip,
{ backgroundColor: appearance.backgroundColor, borderColor: appearance.borderColor },
pressed ? styles.pressed : null,
]}
>
{chip}
{content}
</Pressable>
) : (
<View style={styles.relationshipChip}>{chip}</View>
<View style={[styles.relationshipChip, { backgroundColor: appearance.backgroundColor, borderColor: appearance.borderColor }]}>{content}</View>
)
}
@@ -155,6 +378,24 @@ function normalizeRelationshipTarget(target: string) {
return target.trim().toLowerCase()
}
function formatRelationshipLabel(key: string) {
return key.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
}
function relationshipKeyFromLabel(label: string) {
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
}
function customPropertiesWithoutKey({
key,
properties,
}: {
key: string
properties: Record<string, string>
}) {
return Object.fromEntries(Object.entries(properties).filter(([candidate]) => candidate !== key))
}
function PanelToolbar({ onClose }: { onClose?: () => void }) {
return (
<View style={styles.toolbar}>

View File

@@ -1,16 +1,25 @@
import { useState } from 'react'
import { TextInput, View } from 'react-native'
import { useMemo, useState } from 'react'
import { Pressable, Text, TextInput, View } from 'react-native'
import type { MobileNote } from './mobileNoteProjection'
import { activeMobileWikilinkQuery, insertMobileWikilink, mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
import { styles } from './styles'
export function MobileRawEditor({
notes,
note,
onRawMarkdownChange,
}: {
notes: MobileNote[]
note: MobileNote
onRawMarkdownChange: (markdown: string) => void
}) {
const [draft, setDraft] = useState(note.content)
const [cursor, setCursor] = useState(note.content.length)
const activeQuery = useMemo(() => activeMobileWikilinkQuery({ cursor, markdown: draft }), [cursor, draft])
const suggestions = useMemo(
() => activeQuery ? mobileNoteSuggestions({ excludeNoteId: note.id, notes, query: activeQuery.query }) : [],
[activeQuery, note.id, notes],
)
return (
<View style={styles.rawEditorContent}>
@@ -22,12 +31,32 @@ export function MobileRawEditor({
setDraft(markdown)
onRawMarkdownChange(markdown)
}}
onSelectionChange={(event) => setCursor(event.nativeEvent.selection.start)}
scrollEnabled
spellCheck={false}
style={styles.rawEditorInput}
textAlignVertical="top"
value={draft}
/>
{suggestions.length > 0 && activeQuery ? (
<View style={styles.rawEditorSuggestionMenu}>
{suggestions.map((suggestion) => (
<Pressable
key={suggestion.id}
onPress={() => {
const nextDraft = insertMobileWikilink({ markdown: draft, note: suggestion, query: activeQuery })
setDraft(nextDraft)
setCursor(activeQuery.start + suggestion.id.length + suggestion.title.length + 5)
onRawMarkdownChange(nextDraft)
}}
style={({ pressed }) => [styles.rawEditorSuggestion, pressed ? styles.pressed : null]}
>
<Text style={styles.rawEditorSuggestionTitle}>{suggestion.title}</Text>
<Text style={styles.rawEditorSuggestionMeta}>{suggestion.id}</Text>
</Pressable>
))}
</View>
) : null}
</View>
)
}

View File

@@ -6,12 +6,14 @@ import {
Flag,
GitBranch,
PenNib,
Robot,
Star,
Sun,
Tray,
Wrench,
} from 'phosphor-react-native'
export type IconName = 'archive' | 'books' | 'drop' | 'file-text' | 'flag' | 'git-branch' | 'pen-nib' | 'sun' | 'tray' | 'wrench'
export type IconName = 'archive' | 'books' | 'drop' | 'file-text' | 'flag' | 'git-branch' | 'pen-nib' | 'robot' | 'star' | 'sun' | 'tray' | 'wrench'
const iconByName = {
archive: Archive,
@@ -21,6 +23,8 @@ const iconByName = {
flag: Flag,
'git-branch': GitBranch,
'pen-nib': PenNib,
robot: Robot,
star: Star,
sun: Sun,
tray: Tray,
wrench: Wrench,

View File

@@ -7,6 +7,9 @@ export const demoNoteSources: MobileNoteSource[] = [
{
archived: false,
belongsTo: ['Tolaria MVP'],
customProperties: { review_stage: 'Draft outline' },
favorite: true,
favoriteIndex: 0,
has: ['workflow-orchestration-checklist'],
id: 'workflow',
type: 'Essay',
@@ -15,16 +18,22 @@ export const demoNoteSources: MobileNoteSource[] = [
modified: '6h ago',
filename: 'workflow.md',
relatedTo: ['release', 'mobile-roadmap'],
relationships: { people: ['Malte Ubl'], topics: ['workflow orchestration'] },
tags: ['Design Inspiration', 'Tolaria MVP'],
content: [
'---',
'title: Workflow Orchestration Essay',
'_favorite: true',
'_favorite_index: 0',
'type: Essay',
'status: Draft',
'tags: [Design Inspiration, Tolaria MVP]',
'belongs_to: [Tolaria MVP]',
'related_to: [release, mobile-roadmap]',
'has: [workflow-orchestration-checklist]',
'people: [Malte Ubl]',
'review_stage: Draft outline',
'topics: [workflow orchestration]',
'---',
'',
'# Workflow Orchestration Essay',
@@ -40,6 +49,9 @@ export const demoNoteSources: MobileNoteSource[] = [
{
archived: false,
belongsTo: ['Tolaria MVP'],
customProperties: { platform: 'iPad first' },
favorite: true,
favoriteIndex: 1,
has: ['workflow'],
id: 'mobile-roadmap',
type: 'Project',
@@ -48,16 +60,21 @@ export const demoNoteSources: MobileNoteSource[] = [
modified: '1h ago',
filename: 'mobile-roadmap.md',
relatedTo: ['release'],
relationships: { depends_on: ['workflow-orchestration-checklist'] },
tags: ['Tolaria MVP', 'mobile'],
content: [
'---',
'title: Mobile Roadmap',
'_favorite: true',
'_favorite_index: 1',
'type: Project',
'status: Active',
'tags: [Tolaria MVP, mobile]',
'belongs_to: [Tolaria MVP]',
'related_to: [release]',
'has: [workflow]',
'depends_on: [workflow-orchestration-checklist]',
'platform: iPad first',
'---',
'',
'# Mobile Roadmap',
@@ -72,6 +89,7 @@ export const demoNoteSources: MobileNoteSource[] = [
{
archived: false,
belongsTo: ['workflow'],
customProperties: {},
has: [],
id: 'workflow-orchestration-checklist',
type: 'Note',
@@ -80,6 +98,7 @@ export const demoNoteSources: MobileNoteSource[] = [
modified: '2h ago',
filename: 'workflow-orchestration-checklist.md',
relatedTo: ['workflow'],
relationships: {},
tags: ['mobile'],
content: [
'---',
@@ -101,6 +120,7 @@ export const demoNoteSources: MobileNoteSource[] = [
{
archived: false,
belongsTo: ['Tolaria MVP'],
customProperties: {},
has: [],
id: 'release',
type: 'Release Note',
@@ -109,6 +129,7 @@ export const demoNoteSources: MobileNoteSource[] = [
modified: '12h ago',
filename: 'release.md',
relatedTo: ['workflow', 'mobile-roadmap'],
relationships: {},
tags: ['Release', 'Stable'],
content: [
'---',
@@ -130,6 +151,7 @@ export const demoNoteSources: MobileNoteSource[] = [
{
archived: false,
belongsTo: ['Tolaria MVP'],
customProperties: {},
has: ['resources'],
id: 'migration',
type: 'Project',
@@ -138,6 +160,7 @@ export const demoNoteSources: MobileNoteSource[] = [
modified: '1d ago',
filename: 'migration.md',
relatedTo: ['mobile-roadmap'],
relationships: {},
tags: ['Project', 'Resources'],
content: [
'---',
@@ -158,6 +181,7 @@ export const demoNoteSources: MobileNoteSource[] = [
{
archived: false,
belongsTo: ['migration'],
customProperties: {},
has: [],
id: 'resources',
type: 'Resource',
@@ -166,6 +190,7 @@ export const demoNoteSources: MobileNoteSource[] = [
modified: '3d ago',
filename: 'resources.md',
relatedTo: ['migration', 'mobile-roadmap'],
relationships: {},
tags: ['Resources', 'mobile'],
content: [
'---',
@@ -185,6 +210,7 @@ export const demoNoteSources: MobileNoteSource[] = [
{
archived: true,
belongsTo: [],
customProperties: {},
has: [],
id: 'old-mobile-spike',
type: 'Note',
@@ -193,6 +219,7 @@ export const demoNoteSources: MobileNoteSource[] = [
modified: 'last month',
filename: 'old-mobile-spike.md',
relatedTo: ['mobile-roadmap'],
relationships: {},
tags: ['mobile'],
content: [
'---',

View File

@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { sendMobileAiRequest } from './mobileAiClient'
import type { MobileNote } from './mobileNoteProjection'
describe('mobile AI client', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('sends an OpenAI-compatible chat completion request with note context', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
json: async () => ({ choices: [{ message: { content: 'Answer' } }] }),
ok: true,
} as Response)
await expect(sendMobileAiRequest({
apiKey: 'key',
baseUrl: 'https://api.example.com/v1/',
model: 'model',
note: note(),
prompt: 'Summarize',
})).resolves.toBe('Answer')
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/chat/completions', expect.objectContaining({
method: 'POST',
}))
})
})
function note(): MobileNote {
return {
archived: false,
backlinks: [],
belongsTo: [],
content: '# Workflow\n\nBody',
customProperties: {},
date: '',
favorite: false,
favoriteIndex: null,
has: [],
icon: 'file-text',
id: 'workflow',
modified: '',
outgoingLinks: [],
relatedTo: [],
relationships: {},
snippet: '',
tags: [],
title: 'Workflow',
type: 'Note',
words: 1,
}
}

View File

@@ -0,0 +1,45 @@
import type { MobileNote } from './mobileNoteProjection'
export type MobileAiRequest = {
apiKey: string
baseUrl: string
model: string
note: MobileNote
prompt: string
}
export async function sendMobileAiRequest(request: MobileAiRequest) {
const response = await fetch(`${request.baseUrl.replace(/\/$/, '')}/chat/completions`, {
body: JSON.stringify({
messages: [
{
content: [
'You are helping with a Tolaria markdown note.',
'Use concise answers and preserve [[wikilink]] syntax when referencing notes.',
`Active note: ${request.note.title}`,
request.note.content,
].join('\n\n'),
role: 'system',
},
{ content: request.prompt, role: 'user' },
],
model: request.model,
}),
headers: {
Authorization: `Bearer ${request.apiKey}`,
'Content-Type': 'application/json',
},
method: 'POST',
})
if (!response.ok) {
throw new Error(`AI request failed with ${response.status}`)
}
return extractAssistantMessage(await response.json())
}
function extractAssistantMessage(payload: unknown) {
const content = (payload as { choices?: Array<{ message?: { content?: unknown } }> }).choices?.[0]?.message?.content
return typeof content === 'string' ? content : ''
}

View File

@@ -18,10 +18,14 @@ describe('mobile note frontmatter', () => {
].join('\n'))).toEqual({
archived: true,
belongsTo: ['Tolaria MVP'],
customProperties: {},
date: '2026-05-05',
favorite: undefined,
favoriteIndex: undefined,
has: ['release'],
icon: 'pen-nib',
relatedTo: ['workflow'],
relationships: {},
status: 'Active',
tags: [],
type: 'Essay',
@@ -32,10 +36,14 @@ describe('mobile note frontmatter', () => {
expect(readMobileNoteFrontmatter('---\ntags: [Tolaria MVP, "mobile"]\n---\n# Note')).toEqual({
archived: undefined,
belongsTo: [],
customProperties: {},
date: undefined,
favorite: undefined,
favoriteIndex: undefined,
has: [],
icon: undefined,
relatedTo: [],
relationships: {},
status: undefined,
tags: ['Tolaria MVP', 'mobile'],
type: undefined,
@@ -46,10 +54,14 @@ describe('mobile note frontmatter', () => {
expect(readMobileNoteFrontmatter('# Note')).toEqual({
archived: undefined,
belongsTo: [],
customProperties: {},
date: undefined,
favorite: undefined,
favoriteIndex: undefined,
has: [],
icon: undefined,
relatedTo: [],
relationships: {},
status: undefined,
tags: [],
type: undefined,
@@ -60,13 +72,26 @@ describe('mobile note frontmatter', () => {
expect(readMobileNoteFrontmatter('---\narchived: false\nrelated_to: workflow\ntags: mobile\n---\n# Note')).toEqual({
archived: undefined,
belongsTo: [],
customProperties: {},
date: undefined,
favorite: undefined,
favoriteIndex: undefined,
has: [],
icon: undefined,
relatedTo: [],
relationships: {},
status: undefined,
tags: [],
type: undefined,
})
})
it('reads favorites, custom scalar properties, and custom relationships', () => {
expect(readMobileNoteFrontmatter('---\n_favorite: true\n_favorite_index: 2\npeople: [Luca]\nreview_stage: Draft\n---\n# Note')).toMatchObject({
customProperties: { review_stage: 'Draft' },
favorite: true,
favoriteIndex: 2,
relationships: { people: ['Luca'] },
})
})
})

View File

@@ -3,15 +3,38 @@ import { splitFrontmatter } from '@tolaria/markdown'
export type MobileNoteFrontmatter = {
archived?: boolean
belongsTo: string[]
customProperties: Record<string, string>
date?: string
favorite?: boolean
favoriteIndex?: number
has: string[]
icon?: string
relatedTo: string[]
relationships: Record<string, string[]>
status?: string
tags: string[]
type?: string
}
type FrontmatterEntry = readonly [FrontmatterKey, FrontmatterValue]
type FrontmatterKey = string
type FrontmatterLine = string
type FrontmatterValue = string
const supportedKeys = new Set([
'_favorite',
'_favorite_index',
'archived',
'belongs_to',
'date',
'has',
'icon',
'related_to',
'status',
'tags',
'type',
])
export function readMobileNoteFrontmatter(content: string): MobileNoteFrontmatter {
const [frontmatter] = splitFrontmatter(content)
const lines = frontmatterLines(frontmatter)
@@ -19,10 +42,14 @@ export function readMobileNoteFrontmatter(content: string): MobileNoteFrontmatte
return {
archived: readField({ key: 'archived', lines, parse: parseBooleanField }),
belongsTo: readField({ key: 'belongs_to', lines, parse: parseListField }),
customProperties: readCustomProperties(lines),
date: readField({ key: 'date', lines, parse: parseScalarField }),
favorite: readField({ key: '_favorite', lines, parse: parseBooleanField }),
favoriteIndex: readField({ key: '_favorite_index', lines, parse: parseNumberField }),
has: readField({ key: 'has', lines, parse: parseListField }),
icon: readField({ key: 'icon', lines, parse: parseScalarField }),
relatedTo: readField({ key: 'related_to', lines, parse: parseListField }),
relationships: readCustomRelationships(lines),
status: readField({ key: 'status', lines, parse: parseScalarField }),
tags: readField({ key: 'tags', lines, parse: parseListField }),
type: readField({ key: 'type', lines, parse: parseScalarField }),
@@ -35,37 +62,69 @@ function readField<T>({
parse,
}: {
key: string
lines: string[]
parse: (value: string | undefined) => T
lines: FrontmatterLine[]
parse: (value: FrontmatterValue | undefined) => T
}) {
const value = readRawField({ key, lines })
return parse(value)
}
function parseScalarField(value: string | undefined) {
function parseScalarField(value: FrontmatterValue | undefined) {
return value && !value.startsWith('[') ? unquote(value) : undefined
}
function parseBooleanField(value: string | undefined) {
function parseBooleanField(value: FrontmatterValue | undefined) {
return value === 'true' ? true : undefined
}
function parseListField(value: string | undefined) {
function parseNumberField(value: FrontmatterValue | undefined) {
const parsed = value ? Number(value) : Number.NaN
return Number.isFinite(parsed) ? parsed : undefined
}
function parseListField(value: FrontmatterValue | undefined) {
return value?.startsWith('[') ? readInlineList(value) : []
}
function readCustomProperties(lines: FrontmatterLine[]) {
return Object.fromEntries(
frontmatterEntries(lines)
.filter(([key, value]) => !supportedKeys.has(key) && !value.startsWith('['))
.map(([key, value]) => [key, unquote(value)]),
)
}
function readCustomRelationships(lines: FrontmatterLine[]) {
return Object.fromEntries(
frontmatterEntries(lines)
.filter(([key, value]) => !supportedKeys.has(key) && value.startsWith('['))
.map(([key, value]) => [key, readInlineList(value)]),
)
}
function frontmatterEntries(lines: FrontmatterLine[]): FrontmatterEntry[] {
return lines
.map((line) => {
const separator = line.indexOf(':')
return separator > 0
? [line.slice(0, separator).trim(), line.slice(separator + 1).trim()] as const
: null
})
.filter(isEntry)
}
function readRawField({
key,
lines,
}: {
key: string
lines: string[]
key: FrontmatterKey
lines: FrontmatterLine[]
}) {
const prefix = `${key}:`
return lines.find((line) => line.startsWith(prefix))?.slice(prefix.length).trim()
}
function readInlineList(value: string) {
function readInlineList(value: FrontmatterValue) {
return value
.replace(/^\[|\]$/g, '')
.split(',')
@@ -73,13 +132,17 @@ function readInlineList(value: string) {
.filter(Boolean)
}
function frontmatterLines(frontmatter: string) {
function frontmatterLines(frontmatter: string): FrontmatterLine[] {
return frontmatter
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && line !== '---')
}
function unquote(value: string) {
function unquote(value: FrontmatterValue) {
return value.replace(/^['"]|['"]$/g, '')
}
function isEntry<T>(value: T | null): value is T {
return value !== null
}

View File

@@ -8,16 +8,22 @@ describe('mobile note frontmatter write', () => {
metadata: {
archived: true,
belongsTo: ['Tolaria MVP'],
customProperties: { review_stage: 'Draft' },
date: '2026-05-05',
favorite: true,
favoriteIndex: 0,
has: ['Release Notes'],
icon: 'pen-nib',
relatedTo: ['workflow'],
relationships: { people: ['Luca'] },
status: 'Draft',
tags: ['Tolaria MVP', 'mobile'],
type: 'Essay',
},
})).toBe([
'---',
'_favorite: true',
'_favorite_index: 0',
'archived: true',
'type: Essay',
'status: Draft',
@@ -27,6 +33,8 @@ describe('mobile note frontmatter write', () => {
'related_to: [workflow]',
'has: [Release Notes]',
'tags: [Tolaria MVP, mobile]',
'review_stage: Draft',
'people: [Luca]',
'---',
'# Workflow',
'',
@@ -79,4 +87,24 @@ describe('mobile note frontmatter write', () => {
},
})).toBe('---\nstatus: "Needs: Review"\ntags: [AI/ML, "needs, comma"]\n---\n# Workflow')
})
it('updates dynamic metadata without duplicating old lines', () => {
expect(writeMobileNoteFrontmatter({
content: '---\npeople: [Old]\nreview_stage: Old\n---\n# Workflow',
metadata: {
customProperties: { review_stage: 'Ready' },
relationships: { people: ['New'] },
},
})).toBe('---\nreview_stage: Ready\npeople: [New]\n---\n# Workflow')
})
it('removes dynamic metadata when removal keys are provided', () => {
expect(writeMobileNoteFrontmatter({
content: '---\npeople: [Old]\nreview_stage: Old\n---\n# Workflow',
metadata: {
removedCustomPropertyKeys: ['review_stage'],
removedRelationshipKeys: ['people'],
},
})).toBe('# Workflow')
})
})

View File

@@ -3,16 +3,22 @@ import { splitFrontmatter } from '@tolaria/markdown'
export type WritableMobileNoteFrontmatter = {
archived?: boolean
belongsTo?: string[]
customProperties?: Record<string, string>
date?: string
favorite?: boolean
favoriteIndex?: number | null
has?: string[]
icon?: string
relatedTo?: string[]
removedCustomPropertyKeys?: string[]
removedRelationshipKeys?: string[]
relationships?: Record<string, string[]>
status?: string
tags?: string[]
type?: string
}
const writableKeys = new Set(['archived', 'belongs_to', 'date', 'has', 'icon', 'related_to', 'status', 'tags', 'type'])
const writableKeys = new Set(['_favorite', '_favorite_index', 'archived', 'belongs_to', 'date', 'has', 'icon', 'related_to', 'status', 'tags', 'type'])
export function writeMobileNoteFrontmatter({
content,
@@ -23,7 +29,7 @@ export function writeMobileNoteFrontmatter({
}) {
const [frontmatter, body] = splitFrontmatter(content)
const lines = [
...unknownFrontmatterLines(frontmatter),
...unknownFrontmatterLines({ frontmatter, metadata }),
...supportedFrontmatterLines(metadata),
]
@@ -32,6 +38,8 @@ export function writeMobileNoteFrontmatter({
function supportedFrontmatterLines(metadata: WritableMobileNoteFrontmatter) {
return [
booleanLine({ key: '_favorite', value: metadata.favorite }),
numberLine({ key: '_favorite_index', value: metadata.favoriteIndex }),
booleanLine({ key: 'archived', value: metadata.archived }),
scalarLine({ key: 'type', value: metadata.type }),
scalarLine({ key: 'status', value: metadata.status }),
@@ -41,20 +49,38 @@ function supportedFrontmatterLines(metadata: WritableMobileNoteFrontmatter) {
listLine({ key: 'related_to', values: metadata.relatedTo }),
listLine({ key: 'has', values: metadata.has }),
listLine({ key: 'tags', values: metadata.tags }),
...customPropertyLines(metadata.customProperties),
...customRelationshipLines(metadata.relationships),
].filter(isText)
}
function unknownFrontmatterLines(frontmatter: string) {
function unknownFrontmatterLines({
frontmatter,
metadata,
}: {
frontmatter: string
metadata: WritableMobileNoteFrontmatter
}) {
const dynamicKeys = new Set([
...Object.keys(metadata.customProperties ?? {}),
...Object.keys(metadata.relationships ?? {}),
...(metadata.removedCustomPropertyKeys ?? []),
...(metadata.removedRelationshipKeys ?? []),
])
return frontmatter
.split(/\r?\n/)
.map((line) => line.trim())
.filter(isUnknownFrontmatterLine)
.filter((line) => isUnknownFrontmatterLine({ dynamicKeys, line }))
}
function scalarLine({ key, value }: { key: string; value: string | undefined }) {
return isText(value) ? `${key}: ${yamlValue(value.trim())}` : null
}
function numberLine({ key, value }: { key: string; value: number | null | undefined }) {
return typeof value === 'number' && Number.isFinite(value) ? `${key}: ${value}` : null
}
function booleanLine({ key, value }: { key: string; value: boolean | undefined }) {
return value ? `${key}: true` : null
}
@@ -68,8 +94,30 @@ function yamlValue(value: string) {
return /^[A-Za-z0-9 _/-]+$/.test(value) ? value : JSON.stringify(value)
}
function isUnknownFrontmatterLine(line: string) {
return isText(line) && line !== '---' && !writableKeys.has(line.split(':', 1)[0])
function isUnknownFrontmatterLine({
dynamicKeys,
line,
}: {
dynamicKeys: Set<string>
line: string
}) {
const key = line.split(':', 1)[0]
return isText(line) && line !== '---' && !writableKeys.has(key) && !dynamicKeys.has(key)
}
function customPropertyLines(properties: Record<string, string> | undefined) {
return frontmatterRecordEntries(properties).map(([key, value]) => scalarLine({ key, value }))
}
function customRelationshipLines(relationships: Record<string, string[]> | undefined) {
return frontmatterRecordEntries(relationships).map(([key, values]) => listLine({ key, values }))
}
function frontmatterRecordEntries<T>(record: Record<string, T> | undefined) {
return Object.entries(record ?? {})
.map(([key, value]) => [key.trim(), value] as const)
.filter(([key]) => isText(key) && !writableKeys.has(key))
.sort(([left], [right]) => left.localeCompare(right))
}
function isText(value: string | null | undefined): value is string {

View File

@@ -8,6 +8,9 @@ export type MobileNoteRelationship = {
export type MobileNoteSource = {
archived?: boolean
belongsTo?: string[]
customProperties?: Record<string, string>
favorite?: boolean
favoriteIndex?: number | null
id: string
type: string
has?: string[]
@@ -17,6 +20,7 @@ export type MobileNoteSource = {
filename: string
content: string
relatedTo?: string[]
relationships?: Record<string, string[]>
status?: string
tags: string[]
}
@@ -25,9 +29,13 @@ export type MobileNote = Omit<MobileNoteSource, 'filename'> & {
archived: boolean
backlinks: MobileNoteRelationship[]
belongsTo: string[]
customProperties: Record<string, string>
favorite: boolean
favoriteIndex: number | null
has: string[]
outgoingLinks: string[]
relatedTo: string[]
relationships: Record<string, string[]>
title: string
snippet: string
words: number
@@ -43,6 +51,9 @@ export function projectMobileNote(source: MobileNoteSource): MobileNote {
id: source.id,
archived: source.archived ?? false,
belongsTo: source.belongsTo ?? [],
customProperties: source.customProperties ?? {},
favorite: source.favorite ?? false,
favoriteIndex: source.favoriteIndex ?? null,
type: source.type,
has: source.has ?? [],
icon: source.icon,
@@ -50,6 +61,7 @@ export function projectMobileNote(source: MobileNoteSource): MobileNote {
modified: source.modified,
content: source.content,
relatedTo: source.relatedTo ?? [],
relationships: source.relationships ?? {},
status: source.status,
tags: source.tags,
backlinks: [],

View File

@@ -25,10 +25,17 @@ describe('createMobileNoteFrontmatterPatch', () => {
})).toEqual({
archived: false,
belongsTo: ['Tolaria MVP'],
customProperties: { review_stage: 'Draft outline' },
date: 'May 5, 2026',
favorite: true,
favoriteIndex: 0,
has: [],
icon: 'file-text',
relatedTo: ['release'],
relationships: {
people: ['Malte Ubl'],
topics: ['workflow orchestration'],
},
status: 'Draft',
tags: ['Tolaria'],
type: 'Project',

View File

@@ -15,17 +15,27 @@ export function createMobileNoteFrontmatterPatch({
note: MobileNote
patch: MobileNotePropertyPatch
}): WritableMobileNoteFrontmatter {
return {
const metadata: WritableMobileNoteFrontmatter = {
archived: patch.archived ?? note.archived,
belongsTo: patch.belongsTo ?? note.belongsTo,
customProperties: patch.customProperties ?? note.customProperties,
date: patch.date ?? note.date,
favorite: patch.favorite ?? note.favorite,
favoriteIndex: patch.favoriteIndex ?? note.favoriteIndex,
has: patch.has ?? note.has,
icon: patch.icon ?? note.icon,
relatedTo: patch.relatedTo ?? note.relatedTo,
relationships: patch.relationships ?? note.relationships,
status: patch.status ?? note.status,
tags: patch.tags ?? note.tags,
type: patch.type ?? note.type,
}
return {
...metadata,
...(patch.removedCustomPropertyKeys ? { removedCustomPropertyKeys: patch.removedCustomPropertyKeys } : {}),
...(patch.removedRelationshipKeys ? { removedRelationshipKeys: patch.removedRelationshipKeys } : {}),
}
}
export function isMobileNotePropertySelected({

View File

@@ -16,17 +16,21 @@ export function applyMobileRawNoteContent({
return {
archived: metadata.archived ?? note.archived,
belongsTo: metadata.belongsTo.length > 0 ? metadata.belongsTo : note.belongsTo,
belongsTo: metadata.belongsTo,
content: updatedContent,
customProperties: metadata.customProperties,
date: metadata.date ?? note.date,
favorite: metadata.favorite ?? note.favorite,
favoriteIndex: metadata.favoriteIndex ?? note.favoriteIndex,
filename: `${note.id}.md`,
has: metadata.has.length > 0 ? metadata.has : note.has,
has: metadata.has,
icon: metadata.icon ?? note.icon,
id: note.id,
modified: note.id === noteId ? 'Saved now' : note.modified,
relatedTo: metadata.relatedTo.length > 0 ? metadata.relatedTo : note.relatedTo,
relatedTo: metadata.relatedTo,
relationships: metadata.relationships,
status: metadata.status ?? note.status,
tags: metadata.tags.length > 0 ? metadata.tags : note.tags,
tags: metadata.tags,
type: metadata.type ?? note.type,
}
})

View File

@@ -16,6 +16,7 @@ describe('mobile shortcut commands', () => {
expect(mobileShortcutCommandFromKeyPress({ key: '\\', metaKey: true })).toBe('editToggleRawEditor')
expect(mobileShortcutCommandFromKeyPress({ key: 'i', metaKey: true, shiftKey: true })).toBe('viewToggleProperties')
expect(mobileShortcutCommandFromKeyPress({ key: 'Backspace', metaKey: true })).toBe('noteDelete')
expect(mobileShortcutCommandFromKeyPress({ key: 'd', metaKey: true })).toBe('noteToggleFavorite')
})
it('ignores unmodified and alternative-modified keys', () => {

View File

@@ -2,6 +2,7 @@ export type MobileShortcutCommand =
| 'editToggleRawEditor'
| 'fileNewNote'
| 'noteDelete'
| 'noteToggleFavorite'
| 'viewAll'
| 'viewEditorList'
| 'viewEditorOnly'
@@ -30,6 +31,7 @@ export const mobileShortcutBindings: MobileShortcutBinding[] = [
{ accelerator: 'CmdOrCtrl+Shift+I', command: 'viewToggleProperties' },
{ accelerator: 'CmdOrCtrl+Left', command: 'viewGoBack' },
{ accelerator: 'CmdOrCtrl+Backspace', command: 'noteDelete' },
{ accelerator: 'CmdOrCtrl+D', command: 'noteToggleFavorite' },
]
export function mobileShortcutCommandFromKeyPress(event: MobileShortcutKeyPress) {

View File

@@ -16,6 +16,17 @@ describe('mobile sidebar navigation', () => {
expect(filterNotesForSidebarSelection({ notes, selection: { kind: 'type', type: 'Project' } }).map((item) => item.id)).toEqual(['active'])
})
it('filters favorites and saved nested views', () => {
const notes = [
note({ favorite: true, id: 'essay', status: 'Active', tags: ['mobile'], type: 'Essay' }),
note({ id: 'draft', relatedTo: ['mobile-roadmap'], status: 'Draft', type: 'Note' }),
note({ id: 'ignored', status: 'Done', type: 'Project' }),
]
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'])
})
it('builds sidebar sections from current local notes', () => {
const sections = createMobileSidebarSections([
note({ id: 'draft', status: 'Draft', type: 'Essay' }),
@@ -27,8 +38,9 @@ describe('mobile sidebar navigation', () => {
['Inbox', 1],
['All Notes', 2],
['Archive', 1],
['Favorites', 0],
])
expect(sections[1].items.map((item) => item.label)).toEqual(['Essays', 'Projects'])
expect(sections[2].items.map((item) => item.label)).toEqual(['Essays', 'Projects'])
})
it('formats list titles for library and type selections', () => {
@@ -39,13 +51,19 @@ describe('mobile sidebar navigation', () => {
function note({
archived = false,
favorite = false,
id,
relatedTo = [],
status,
tags = [],
type,
}: {
archived?: boolean
favorite?: boolean
id: string
relatedTo?: string[]
status?: string
tags?: string[]
type: string
}): MobileNote {
return {
@@ -53,16 +71,20 @@ function note({
backlinks: [],
belongsTo: [],
content: `# ${id}`,
customProperties: {},
date: '',
favorite,
favoriteIndex: null,
has: [],
icon: 'file-text',
id,
modified: '',
outgoingLinks: [],
relatedTo: [],
relatedTo,
relationships: {},
snippet: '',
status,
tags: [],
tags,
title: id,
type,
words: 1,

View File

@@ -1,9 +1,11 @@
import type { IconName } from './NamedIcon'
import type { MobileNote } from './mobileNoteProjection'
import { evaluateMobileView, mobileViewDefinitions } from './mobileViewFilters'
export type MobileSidebarSelection =
| { kind: 'library'; id: 'all' | 'archive' | 'inbox' }
| { kind: 'library'; id: 'all' | 'archive' | 'favorites' | 'inbox' }
| { kind: 'type'; type: string }
| { kind: 'view'; id: string }
export type MobileSidebarItem = {
count: number
@@ -27,8 +29,18 @@ export function createMobileSidebarSections(notes: MobileNote[]): MobileSidebarS
libraryItem({ count: inboxNotes(notes).length, icon: 'tray', id: 'inbox', label: 'Inbox' }),
libraryItem({ count: activeNotes(notes).length, icon: 'file-text', id: 'all', label: 'All Notes' }),
libraryItem({ count: archivedNotes(notes).length, icon: 'archive', id: 'archive', label: 'Archive' }),
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 },
})),
},
{
title: 'Types',
items: noteTypes(notes).map((type) => ({
@@ -52,18 +64,33 @@ export function filterNotesForSidebarSelection({
return activeNotes(notes).filter((note) => note.type === selection.type)
}
if (selection.kind === 'view') {
const view = mobileViewDefinitions.find((definition) => definition.id === selection.id)
return view ? evaluateMobileView({ notes: activeNotes(notes), view }) : []
}
switch (selection.id) {
case 'all':
return activeNotes(notes)
case 'archive':
return archivedNotes(notes)
case 'favorites':
return favoriteNotes(notes)
default:
return inboxNotes(notes)
}
}
export function mobileSidebarTitle(selection: MobileSidebarSelection) {
return selection.kind === 'type' ? pluralTypeLabel(selection.type) : libraryTitle(selection.id)
if (selection.kind === 'type') {
return pluralTypeLabel(selection.type)
}
if (selection.kind === 'view') {
return mobileViewDefinitions.find((definition) => definition.id === selection.id)?.name ?? 'View'
}
return libraryTitle(selection.id)
}
export function isMobileSidebarSelectionActive({
@@ -85,7 +112,7 @@ function libraryItem({
}: {
count: number
icon: IconName
id: 'all' | 'archive' | 'inbox'
id: 'all' | 'archive' | 'favorites' | 'inbox'
label: string
}): MobileSidebarItem {
return {
@@ -108,12 +135,21 @@ function archivedNotes(notes: MobileNote[]) {
return notes.filter((note) => note.archived)
}
function favoriteNotes(notes: MobileNote[]) {
return activeNotes(notes)
.filter((note) => note.favorite)
.sort((left, right) => (left.favoriteIndex ?? Number.MAX_SAFE_INTEGER) - (right.favoriteIndex ?? Number.MAX_SAFE_INTEGER))
}
function noteTypes(notes: MobileNote[]) {
return [...new Set(activeNotes(notes).map((note) => note.type))].sort()
}
function libraryTitle(id: 'all' | 'archive' | 'inbox') {
return id === 'all' ? 'All Notes' : id === 'archive' ? 'Archive' : 'Inbox'
function libraryTitle(id: 'all' | 'archive' | 'favorites' | 'inbox') {
if (id === 'all') return 'All Notes'
if (id === 'archive') return 'Archive'
if (id === 'favorites') return 'Favorites'
return 'Inbox'
}
function pluralTypeLabel(type: string) {

View File

@@ -0,0 +1,48 @@
export type MobileChipAppearance = {
backgroundColor: string
borderColor: string
color: string
}
const neutralAppearance: MobileChipAppearance = {
backgroundColor: '#f6f5f2',
borderColor: '#dedbd4',
color: '#66615a',
}
const appearances: Record<string, MobileChipAppearance> = {
Essay: {
backgroundColor: '#eaf6ee',
borderColor: '#b8dec5',
color: '#3f7f5f',
},
Evergreen: {
backgroundColor: '#edf6e4',
borderColor: '#c8dfaa',
color: '#5f7f36',
},
Note: neutralAppearance,
Project: {
backgroundColor: '#eef3ff',
borderColor: '#c7d8ff',
color: '#356fd6',
},
Resource: {
backgroundColor: '#f4edff',
borderColor: '#ddcaff',
color: '#7a54b8',
},
'Release Note': {
backgroundColor: '#fff2e6',
borderColor: '#f3cfaa',
color: '#a56620',
},
}
export function mobileTypeAppearance(type: string) {
return appearances[type] ?? neutralAppearance
}
export function mobileRelationshipAppearance(type?: string) {
return type ? mobileTypeAppearance(type) : neutralAppearance
}

View File

@@ -60,6 +60,9 @@ function fileToSource(file: MobileVaultFile): MobileNoteSource {
id: fileId(file.path),
archived: metadata.archived ?? false,
belongsTo: metadata.belongsTo,
customProperties: metadata.customProperties,
favorite: metadata.favorite ?? false,
favoriteIndex: metadata.favoriteIndex ?? null,
type: metadata.type ?? 'Note',
has: metadata.has,
icon: metadata.icon ?? 'file-text',
@@ -68,6 +71,7 @@ function fileToSource(file: MobileVaultFile): MobileNoteSource {
filename: file.path,
content: file.content,
relatedTo: metadata.relatedTo,
relationships: metadata.relationships,
status: metadata.status,
tags: metadata.tags,
}

View File

@@ -56,13 +56,17 @@ function note({ id }: { id: string }): MobileNote {
backlinks: [],
belongsTo: [],
content: '',
customProperties: {},
date: 'today',
favorite: false,
favoriteIndex: null,
has: [],
icon: 'essay',
id,
modified: 'today',
outgoingLinks: [],
relatedTo: [],
relationships: {},
snippet: '',
tags: [],
title: id,

View File

@@ -0,0 +1,177 @@
import type { MobileNote } from './mobileNoteProjection'
export type MobileFilterOp =
| 'after'
| 'any_of'
| 'before'
| 'contains'
| 'equals'
| 'is_empty'
| 'is_not_empty'
| 'none_of'
| 'not_contains'
| 'not_equals'
export type MobileFilterCondition = {
field: string
op: MobileFilterOp
value?: string | string[]
}
export type MobileFilterGroup = { all: MobileFilterNode[] } | { any: MobileFilterNode[] }
export type MobileFilterNode = MobileFilterCondition | MobileFilterGroup
export type MobileViewDefinition = {
color: string
filters: MobileFilterGroup
icon: string
id: string
name: string
}
export const mobileViewDefinitions: MobileViewDefinition[] = [
{
color: '#3f7f5f',
filters: {
all: [
{ field: 'favorite', op: 'equals', value: 'true' },
{ any: [{ field: 'type', op: 'equals', value: 'Essay' }, { field: 'tags', op: 'contains', value: 'mobile' }] },
],
},
icon: 'sun',
id: 'favorite-mobile-work',
name: 'Favorite mobile work',
},
{
color: '#356fd6',
filters: {
all: [
{ field: 'archived', op: 'not_equals', value: 'true' },
{ any: [{ field: 'status', op: 'equals', value: 'Draft' }, { field: 'related_to', op: 'contains', value: 'mobile-roadmap' }] },
],
},
icon: 'git-branch',
id: 'active-drafts',
name: 'Active drafts',
},
]
export function evaluateMobileView({
notes,
view,
}: {
notes: MobileNote[]
view: MobileViewDefinition
}) {
return notes.filter((note) => evaluateGroup({ group: view.filters, note }))
}
type MobileFieldValue = string | string[] | undefined
type OperationMatcher = (value: MobileFieldValue, expected: string | string[] | undefined) => boolean
const operationMatchers: Record<MobileFilterOp, OperationMatcher> = {
after: (value, expected) => scalarValue(value) > scalarValue(expected),
any_of: (value, expected) => arrayValues(expected).some((item) => arrayValues(value).some((actual) => wikilinkEquals(actual, item))),
before: (value, expected) => scalarValue(value) < scalarValue(expected),
contains: (value, expected) => arrayValues(value).some((item) => containsValue({ actual: item, expected: scalarValue(expected) })),
equals: (value, expected) => scalarValue(value) === scalarValue(expected),
is_empty: (value) => arrayValues(value).length === 0 || arrayValues(value).every((item) => item.length === 0),
is_not_empty: (value) => arrayValues(value).some((item) => item.length > 0),
none_of: (value, expected) => !operationMatchers.any_of(value, expected),
not_contains: (value, expected) => !operationMatchers.contains(value, expected),
not_equals: (value, expected) => !operationMatchers.equals(value, expected),
}
function evaluateGroup({
group,
note,
}: {
group: MobileFilterGroup
note: MobileNote
}): boolean {
return 'all' in group
? group.all.every((node) => evaluateNode({ node, note }))
: group.any.some((node) => evaluateNode({ node, note }))
}
function evaluateNode({
node,
note,
}: {
node: MobileFilterNode
note: MobileNote
}) {
return isFilterGroup(node) ? evaluateGroup({ group: node, note }) : evaluateCondition({ condition: node, note })
}
function evaluateCondition({
condition,
note,
}: {
condition: MobileFilterCondition
note: MobileNote
}) {
return operationMatchers[condition.op](valueForField({ field: condition.field, note }), condition.value)
}
function valueForField({
field,
note,
}: {
field: string
note: MobileNote
}) {
return standardFieldValues(note)[field.toLowerCase()] ?? note.relationships[field] ?? note.customProperties[field] ?? ''
}
function standardFieldValues(note: MobileNote): Record<string, MobileFieldValue> {
return {
archived: String(note.archived),
belongs_to: note.belongsTo,
favorite: String(note.favorite),
has: note.has,
related_to: note.relatedTo,
status: note.status ?? '',
tags: note.tags,
type: note.type,
}
}
function containsValue({
actual,
expected,
}: {
actual: string
expected: string
}) {
return expected.startsWith('[[') ? wikilinkEquals(actual, expected) : wikilinkStem(actual).includes(wikilinkStem(expected))
}
function wikilinkEquals(left: string, right: string) {
return wikilinkParts(left).some((part) => wikilinkParts(right).includes(part))
}
function wikilinkParts(value: string) {
const [target, alias] = wikilinkStem(value).split('|')
return [target, alias].filter(isText)
}
function wikilinkStem(value: string) {
return value.trim().replace(/^\[\[|\]\]$/g, '').toLowerCase()
}
function scalarValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] ?? '' : value ?? ''
}
function arrayValues(value: string | string[] | undefined) {
return Array.isArray(value) ? value : isText(value) ? [value] : []
}
function isFilterGroup(node: MobileFilterNode): node is MobileFilterGroup {
return 'all' in node || 'any' in node
}
function isText(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { activeMobileWikilinkQuery, insertMobileWikilink, mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
import type { MobileNote } from './mobileNoteProjection'
describe('mobile wikilink autocomplete', () => {
it('detects an active wikilink trigger before the cursor', () => {
expect(activeMobileWikilinkQuery({ cursor: 12, markdown: 'See [[mobile' })).toEqual({
end: 12,
query: 'mobile',
start: 4,
})
})
it('suggests notes by title or id and inserts canonical aliases', () => {
const notes = [note({ id: 'mobile-roadmap', title: 'Mobile Roadmap' }), note({ id: 'workflow', title: 'Workflow Essay' })]
const query = activeMobileWikilinkQuery({ cursor: 12, markdown: 'See [[mobile' })
expect(mobileNoteSuggestions({ notes, query: 'road' }).map((item) => item.id)).toEqual(['mobile-roadmap'])
expect(query ? insertMobileWikilink({ markdown: 'See [[mobile', note: notes[0], query }) : '').toBe('See [[mobile-roadmap|Mobile Roadmap]]')
})
})
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: '',
tags: [],
title,
type: 'Note',
words: 1,
}
}

View File

@@ -0,0 +1,71 @@
import type { MobileNote } from './mobileNoteProjection'
export type MobileWikilinkQuery = {
end: number
query: string
start: number
}
const maxSuggestions = 6
export function activeMobileWikilinkQuery({
cursor,
markdown,
}: {
cursor: number
markdown: string
}): MobileWikilinkQuery | null {
const prefix = markdown.slice(0, cursor)
const start = prefix.lastIndexOf('[[')
if (start < 0 || prefix.slice(start).includes(']]')) {
return null
}
const query = prefix.slice(start + 2)
return query.includes('\n') ? null : { end: cursor, query, start }
}
export function mobileNoteSuggestions({
excludeNoteId,
notes,
query,
}: {
excludeNoteId?: string
notes: MobileNote[]
query: string
}) {
const normalizedQuery = normalizeSuggestionText(query)
return notes
.filter((note) => note.id !== excludeNoteId)
.filter((note) => matchesSuggestion({ note, normalizedQuery }))
.slice(0, maxSuggestions)
}
export function insertMobileWikilink({
markdown,
note,
query,
}: {
markdown: string
note: MobileNote
query: MobileWikilinkQuery
}) {
const wikilink = `[[${note.id}|${note.title}]]`
return `${markdown.slice(0, query.start)}${wikilink}${markdown.slice(query.end)}`
}
function matchesSuggestion({
note,
normalizedQuery,
}: {
note: MobileNote
normalizedQuery: string
}) {
return normalizedQuery.length === 0
|| normalizeSuggestionText(note.id).includes(normalizedQuery)
|| normalizeSuggestionText(note.title).includes(normalizedQuery)
}
function normalizeSuggestionText(value: string) {
return value.trim().toLowerCase()
}

View File

@@ -1,11 +1,15 @@
import { aiStyles } from './styles/aiStyles'
import { breadcrumbStyles } from './styles/breadcrumbStyles'
import { commonStyles } from './styles/commonStyles'
import { customPropertyStyles } from './styles/customPropertyStyles'
import { editorSaveStateStyles } from './styles/editorSaveStateStyles'
import { editorStyles } from './styles/editorStyles'
import { gitSyncStyles } from './styles/gitSyncStyles'
import { noteListStyles } from './styles/noteListStyles'
import { propertiesStyles } from './styles/propertiesStyles'
import { propertyChipStyles } from './styles/propertyChipStyles'
import { rawEditorSuggestionStyles } from './styles/rawEditorSuggestionStyles'
import { relationshipEditingStyles } from './styles/relationshipEditingStyles'
import { relationshipStyles } from './styles/relationshipStyles'
import { remotePromptStyles } from './styles/remotePromptStyles'
import { sidebarStyles } from './styles/sidebarStyles'
@@ -13,14 +17,18 @@ import { vaultManagementStyles } from './styles/vaultManagementStyles'
import { vaultLoadStyles } from './styles/vaultLoadStyles'
export const styles = {
...aiStyles,
...breadcrumbStyles,
...commonStyles,
...customPropertyStyles,
...editorStyles,
...editorSaveStateStyles,
...gitSyncStyles,
...noteListStyles,
...propertiesStyles,
...propertyChipStyles,
...rawEditorSuggestionStyles,
...relationshipEditingStyles,
...relationshipStyles,
...remotePromptStyles,
...sidebarStyles,

View File

@@ -0,0 +1,50 @@
import { StyleSheet } from 'react-native'
import { colors, spacing } from '../theme'
export const aiStyles = StyleSheet.create({
aiContent: {
gap: spacing.sm,
padding: spacing.lg,
},
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',
},
aiResponse: {
marginTop: spacing.md,
color: colors.text,
fontSize: 15,
lineHeight: 22,
},
})

View File

@@ -0,0 +1,42 @@
import { StyleSheet } from 'react-native'
import { colors, spacing } from '../theme'
export const customPropertyStyles = StyleSheet.create({
customPropertyRow: {
minHeight: 38,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
borderBottomColor: colors.border,
borderBottomWidth: StyleSheet.hairlineWidth,
},
customPropertyKey: {
width: 90,
color: colors.mutedText,
fontSize: 13,
fontWeight: '600',
},
customPropertyValue: {
flex: 1,
color: colors.text,
fontSize: 14,
fontWeight: '600',
textAlign: 'right',
},
customPropertyAddRow: {
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.sm,
},
customPropertyAddInput: {
minHeight: 36,
flex: 1,
borderColor: colors.border,
borderRadius: 10,
borderWidth: StyleSheet.hairlineWidth,
color: colors.text,
fontSize: 13,
paddingHorizontal: spacing.sm,
backgroundColor: colors.canvas,
},
})

View File

@@ -0,0 +1,33 @@
import { StyleSheet } from 'react-native'
import { colors, spacing } from '../theme'
export const rawEditorSuggestionStyles = StyleSheet.create({
rawEditorSuggestionMenu: {
position: 'absolute',
right: spacing.xl,
bottom: spacing.xl,
left: spacing.xl,
borderColor: colors.border,
borderRadius: 12,
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
backgroundColor: colors.canvas,
},
rawEditorSuggestion: {
minHeight: 46,
justifyContent: 'center',
borderBottomColor: colors.border,
borderBottomWidth: StyleSheet.hairlineWidth,
paddingHorizontal: spacing.md,
},
rawEditorSuggestionTitle: {
color: colors.text,
fontSize: 14,
fontWeight: '700',
},
rawEditorSuggestionMeta: {
marginTop: 2,
color: colors.mutedText,
fontSize: 12,
},
})

View File

@@ -0,0 +1,44 @@
import { StyleSheet } from 'react-native'
import { colors, spacing } from '../theme'
export const relationshipEditingStyles = StyleSheet.create({
relationshipHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
relationshipAddButton: {
width: 26,
height: 26,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 13,
backgroundColor: colors.canvas,
},
relationshipAddBox: {
marginTop: spacing.sm,
gap: spacing.xs,
},
relationshipInput: {
minHeight: 36,
borderColor: colors.border,
borderRadius: 10,
borderWidth: StyleSheet.hairlineWidth,
color: colors.text,
fontSize: 14,
paddingHorizontal: spacing.md,
backgroundColor: colors.canvas,
},
relationshipSuggestion: {
minHeight: 32,
justifyContent: 'center',
borderRadius: 8,
paddingHorizontal: spacing.md,
backgroundColor: colors.primarySoft,
},
relationshipSuggestionText: {
color: colors.text,
fontSize: 13,
fontWeight: '600',
},
})

View File

@@ -13,6 +13,9 @@ export const relationshipStyles = StyleSheet.create({
},
relationshipChip: {
minHeight: 30,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
justifyContent: 'center',
borderColor: colors.border,
borderRadius: 999,
@@ -25,4 +28,10 @@ export const relationshipStyles = StyleSheet.create({
fontSize: 13,
fontWeight: '700',
},
relationshipRemoveButton: {
width: 18,
height: 18,
alignItems: 'center',
justifyContent: 'center',
},
})

View File

@@ -8,7 +8,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Branch: `codex/mobile`
- Active phase: Phase 4 - Editor V1
- Active slice: Git credential/auth boundaries and editor durability
- Active slice: Local workflow parity, relationships, views, favorites, and AI panel
- Push policy: commit locally; do not push unless explicitly requested
- Validation target: iPad/iOS simulator first
@@ -112,14 +112,23 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Upgraded the mobile properties prototype so type, status, date, and tags can be entered from the keyboard with quick chips as shortcuts, while icon editing stays chip-based.
- Removed the now-unused title prompt component and kept the compose-button disabled state with the note-list styles.
- Split new breadcrumb styles out of the shared toolbar styles and removed stale editor/property style entries so the CodeScene pre-commit safeguard stays green.
- Added first-class mobile metadata projection for `_favorite`, `_favorite_index`, custom scalar properties, and dynamic relationship frontmatter fields.
- Added editable mobile relationship groups with inline note autocomplete, removable relationship chips, addable custom relationship groups, and add/remove custom scalar properties.
- Added raw Markdown wikilink autocomplete that detects active `[[...` queries, suggests local notes, and inserts canonical aliased wikilinks.
- Added Favorites to the mobile sidebar and breadcrumb star toggle, including the desktop-aligned `CmdOrCtrl+D` shortcut command.
- 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.
- Set the booted iPad simulator keyboard preferences to Italian (`it_IT@sw=QWERTY;hw=Automatic`).
## Next Action
Continue Phase 4 with editor durability:
Continue Phase 4 with local workflow parity and native QA:
1. Continue TenTap Markdown serialization coverage for any editor output observed in simulator QA.
2. Implement the native Git module behind `createNativeMobileGitTransport`, preferably Rust/libgit2 unless Expo native-module constraints block it.
3. Retry the iOS development-client build after installing an iOS 26.2 simulator runtime in Xcode.
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.
## Verification Log
@@ -178,6 +187,13 @@ Continue Phase 4 with editor durability:
- `xcrun simctl boot 40724AA3-A793-41D8-9C66-79745DA28DE4` booted `iPad Pro 13-inch (M4)`.
- `pnpm --filter @tolaria/mobile exec expo start --ios` installed/opened Expo Go and launched Tolaria on the booted iPad simulator.
- Simulator screenshot captured at `/tmp/tolaria-mobile-ipad.png`; the Tolaria shell renders behind Expo Go's first-run tools modal.
- `pnpm --filter @tolaria/mobile typecheck` passed after local workflow parity additions.
- `VITEST_MAX_THREADS=1 VITEST_MIN_THREADS=1 pnpm --filter @tolaria/mobile test` passed after local workflow parity additions: 52 files / 172 tests.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export-workflow` passed after local workflow parity additions.
- CodeScene touched-file checks passed at `10.0` for mobile metadata, sidebar navigation, raw wikilink autocomplete, saved view filters, AI client/panel, type appearance, style modules, and touched app/panel components; non-scorable fixture/style aggregation files returned no score.
- 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 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.