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:
saravanakumardb1 2026-04-06 08:23:55 -07:00
parent 564e8f72dc
commit 37d7284730
3 changed files with 229 additions and 4 deletions

View 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)}`,
);
}

View File

@ -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>
);

View 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 });
},
}));