feat(mobile): add Smart Actions — API client, prompt store, note detail integration
Phase 4 of Smart Actions Roadmap: - Create mobile/src/api/note-prompts.ts: typed API client - listPromptTemplates, runPrompt, suggestTags, getReadingTime - Create mobile/src/store/prompt-store.ts: Zustand store - fetchTemplates, runPrompt, clearResult, clearError - Enhance note detail screen (mobile/src/app/note/[id].tsx): - Collapsible Smart Actions section with toggle - Reading time display on expand - Suggest tags button with inline tag display - Prompt template list (top 6) with one-tap execution - ActivityIndicator during prompt execution - Inline result card with model/token info and dismiss - Mobile typecheck passes
This commit is contained in:
parent
564e8f72dc
commit
37d7284730
61
mobile/src/api/note-prompts.ts
Normal file
61
mobile/src/api/note-prompts.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { getApiClient } from './client';
|
||||
|
||||
export type PromptCategory = 'transform' | 'extract' | 'generate' | 'analysis' | 'vision' | 'export' | 'custom';
|
||||
export type PromptInputType = 'text' | 'image' | 'text+image' | 'multi-note';
|
||||
export type PromptOutputType = 'new_note' | 'artifact' | 'update_note';
|
||||
|
||||
export type MobilePromptTemplate = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: PromptCategory;
|
||||
inputType: PromptInputType;
|
||||
outputType: PromptOutputType;
|
||||
builtIn: boolean;
|
||||
};
|
||||
|
||||
export type RunPromptInput = {
|
||||
templateId: string;
|
||||
noteId: string;
|
||||
workspaceId: string;
|
||||
imageUrl?: string;
|
||||
parameters?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type RunPromptOutput = {
|
||||
content: string;
|
||||
templateSlug: string;
|
||||
outputType: PromptOutputType;
|
||||
model?: string;
|
||||
usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
|
||||
};
|
||||
|
||||
export async function listPromptTemplates(): Promise<MobilePromptTemplate[]> {
|
||||
const res = await getApiClient().fetch<{ items: MobilePromptTemplate[] }>('/note-prompts');
|
||||
return res.items;
|
||||
}
|
||||
|
||||
export async function runPrompt(input: RunPromptInput): Promise<RunPromptOutput> {
|
||||
return getApiClient().fetch<RunPromptOutput>('/note-prompts/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function suggestTags(noteId: string, workspaceId: string): Promise<string[]> {
|
||||
const res = await getApiClient().fetch<{ tags: string[] }>(
|
||||
`/notes/${encodeURIComponent(noteId)}/suggest-tags`,
|
||||
{ method: 'POST', body: JSON.stringify({ workspaceId }) },
|
||||
);
|
||||
return res.tags;
|
||||
}
|
||||
|
||||
export async function getReadingTime(
|
||||
noteId: string,
|
||||
workspaceId: string,
|
||||
): Promise<{ wordCount: number; readingTimeMinutes: number }> {
|
||||
return getApiClient().fetch(
|
||||
`/notes/${encodeURIComponent(noteId)}/reading-time?workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { useNotesStore, type NotesState } from '../../store/notes-store';
|
||||
import { usePromptStore, type PromptState } from '../../store/prompt-store';
|
||||
import { getReadingTime, suggestTags } from '../../api/note-prompts';
|
||||
import { colors } from '../../theme';
|
||||
|
||||
export default function NoteDetailScreen() {
|
||||
@ -11,9 +13,18 @@ export default function NoteDetailScreen() {
|
||||
const isLoading = useNotesStore((state: NotesState) => state.isLoading);
|
||||
const openNote = useNotesStore((state: NotesState) => state.openNote);
|
||||
const updateNote = useNotesStore((state: NotesState) => state.updateNote);
|
||||
const templates = usePromptStore((state: PromptState) => state.templates);
|
||||
const isRunning = usePromptStore((state: PromptState) => state.isRunning);
|
||||
const lastResult = usePromptStore((state: PromptState) => state.lastResult);
|
||||
const fetchTemplates = usePromptStore((state: PromptState) => state.fetchTemplates);
|
||||
const runPromptAction = usePromptStore((state: PromptState) => state.runPrompt);
|
||||
const clearResult = usePromptStore((state: PromptState) => state.clearResult);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState('');
|
||||
const [draftBody, setDraftBody] = useState('');
|
||||
const [readingTime, setReadingTime] = useState<{ wordCount: number; readingTimeMinutes: number } | null>(null);
|
||||
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
|
||||
const [showSmartActions, setShowSmartActions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void openNote(noteId);
|
||||
@ -98,10 +109,99 @@ export default function NoteDetailScreen() {
|
||||
<Text style={styles.body}>Last updated: {formattedUpdatedAt}</Text>
|
||||
</View>
|
||||
|
||||
{/* Smart Actions */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.sectionTitle}>Current mobile scope</Text>
|
||||
<Text style={styles.body}>Artifacts and review context continue to load from the fuller web surface.</Text>
|
||||
<Text style={styles.body}>This mobile screen now focuses on reading and updating the persisted note body.</Text>
|
||||
<Pressable
|
||||
accessibilityLabel="Toggle Smart Actions"
|
||||
style={styles.actionRow}
|
||||
onPress={() => {
|
||||
setShowSmartActions(!showSmartActions);
|
||||
if (!showSmartActions && templates.length === 0) {
|
||||
void fetchTemplates();
|
||||
}
|
||||
if (!showSmartActions && selectedNote) {
|
||||
void getReadingTime(noteId, selectedNote.workspaceId).then(setReadingTime).catch(() => {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.sectionTitle}>✨ Smart Actions</Text>
|
||||
<Text style={styles.body}>{showSmartActions ? '▲' : '▼'}</Text>
|
||||
</Pressable>
|
||||
|
||||
{showSmartActions && (
|
||||
<View style={{ gap: 10, marginTop: 6 }}>
|
||||
{readingTime && (
|
||||
<Text style={styles.body}>
|
||||
⏱ {readingTime.readingTimeMinutes} min read · {readingTime.wordCount} words
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
accessibilityLabel="Suggest tags"
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => {
|
||||
if (!selectedNote) return;
|
||||
void suggestTags(noteId, selectedNote.workspaceId)
|
||||
.then(setSuggestedTags)
|
||||
.catch(() => {});
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Suggest tags</Text>
|
||||
</Pressable>
|
||||
|
||||
{suggestedTags.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
|
||||
{suggestedTags.map((tag) => (
|
||||
<Text key={tag} style={[styles.badge, { fontSize: 12, paddingHorizontal: 8, paddingVertical: 2 }]}>
|
||||
{tag}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{templates.slice(0, 6).map((t) => (
|
||||
<Pressable
|
||||
key={t.id}
|
||||
accessibilityLabel={`Run: ${t.name}`}
|
||||
style={[styles.secondaryButton, isRunning ? { opacity: 0.5 } : null]}
|
||||
disabled={isRunning || !selectedNote}
|
||||
onPress={() => {
|
||||
if (!selectedNote) return;
|
||||
void runPromptAction({
|
||||
templateId: t.slug,
|
||||
noteId,
|
||||
workspaceId: selectedNote.workspaceId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>{t.name}</Text>
|
||||
<Text style={[styles.body, { fontSize: 12 }]}>{t.description}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
|
||||
{isRunning && (
|
||||
<View style={{ alignItems: 'center', paddingVertical: 10 }}>
|
||||
<ActivityIndicator color={colors.accentPrimary} />
|
||||
<Text style={[styles.body, { marginTop: 6 }]}>Running…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{lastResult && (
|
||||
<View style={[styles.card, { backgroundColor: colors.bgElevated }]}>
|
||||
<Text style={styles.sectionTitle}>Result</Text>
|
||||
<Text style={styles.body} numberOfLines={20}>{lastResult.content}</Text>
|
||||
{lastResult.model && (
|
||||
<Text style={[styles.body, { fontSize: 11 }]}>
|
||||
{lastResult.model} · {lastResult.usage?.totalTokens ?? 0} tokens
|
||||
</Text>
|
||||
)}
|
||||
<Pressable accessibilityLabel="Dismiss result" style={styles.secondaryButton} onPress={clearResult}>
|
||||
<Text style={styles.secondaryButtonText}>Dismiss</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
64
mobile/src/store/prompt-store.ts
Normal file
64
mobile/src/store/prompt-store.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
listPromptTemplates,
|
||||
runPrompt,
|
||||
type MobilePromptTemplate,
|
||||
type RunPromptInput,
|
||||
type RunPromptOutput,
|
||||
} from '../api/note-prompts';
|
||||
|
||||
export type PromptState = {
|
||||
templates: MobilePromptTemplate[];
|
||||
isLoading: boolean;
|
||||
isRunning: boolean;
|
||||
lastResult: RunPromptOutput | null;
|
||||
error: string | null;
|
||||
fetchTemplates: () => Promise<void>;
|
||||
runPrompt: (input: RunPromptInput) => Promise<RunPromptOutput | null>;
|
||||
clearResult: () => void;
|
||||
clearError: () => void;
|
||||
};
|
||||
|
||||
export const usePromptStore = create<PromptState>((set) => ({
|
||||
templates: [],
|
||||
isLoading: false,
|
||||
isRunning: false,
|
||||
lastResult: null,
|
||||
error: null,
|
||||
|
||||
async fetchTemplates() {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const templates = await listPromptTemplates();
|
||||
set({ templates, isLoading: false });
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to load templates',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async runPrompt(input: RunPromptInput) {
|
||||
set({ isRunning: true, error: null, lastResult: null });
|
||||
try {
|
||||
const result = await runPrompt(input);
|
||||
set({ lastResult: result, isRunning: false });
|
||||
return result;
|
||||
} catch (err) {
|
||||
set({
|
||||
isRunning: false,
|
||||
error: err instanceof Error ? err.message : 'Prompt execution failed',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
clearResult() {
|
||||
set({ lastResult: null });
|
||||
},
|
||||
|
||||
clearError() {
|
||||
set({ error: null });
|
||||
},
|
||||
}));
|
||||
Loading…
Reference in New Issue
Block a user