feat(smart-actions): add prompt-result screen, capture sub-routes, Cmd+Shift+A shortcut, telemetry events (G16-G19)

This commit is contained in:
saravanakumardb1 2026-04-06 13:43:47 -07:00
parent 63ee00037e
commit 6095f1d985
8 changed files with 321 additions and 1 deletions

View File

@ -8,6 +8,7 @@
import { isFeatureEnabled } from './feature-flags.js';
import { embedText, stripHtmlForEmbedding } from './embeddings.js';
import { llm } from './llm.js';
import { trackEvent } from './telemetry.js';
import type { NoteDoc } from '../modules/notes/types.js';
import { getCollection } from './datastore.js';
import type { FastifyBaseLogger } from 'fastify';
@ -131,6 +132,7 @@ async function backgroundAutoSummarize(
updatedBy: 'system',
});
trackEvent('auto_summarize_triggered', note.userId, { wordCount: String(wordCount), noteId: note.id });
log.info({ noteId: note.id, artifactId: artifact.id }, 'auto-summary generated');
} catch (err) {
log.warn({ noteId: note.id, err }, 'background auto-summarize failed');

View File

@ -296,7 +296,11 @@ export async function notePromptRoutes(app: FastifyInstance): Promise<void> {
}
duplicates.sort((a, b) => b.similarity - a.similarity);
return { duplicates: duplicates.slice(0, input.limit) };
const results = duplicates.slice(0, input.limit);
for (const dup of results) {
trackEvent('duplicate_detected', userId, { similarityScore: String(dup.similarity), noteId: id });
}
return { duplicates: results };
});
// ── Suggest related notes to link (F9) ──────────────────────────

View File

@ -243,6 +243,10 @@ export async function noteRoutes(app: RouteApp) {
const created = await repo.createNote(doc);
trackEvent('note.created', auth.sub, { noteId: created.id, workspaceId: created.workspaceId });
if (created.sourceType === 'voice') {
const wordCount = (created.body ?? '').split(/\s+/).filter(Boolean).length;
trackEvent('voice_capture_completed', auth.sub, { noteId: created.id, wordCount: String(wordCount) });
}
reply.code(201);
return created;
});

View File

@ -0,0 +1,46 @@
import { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { colors } from '../../../theme';
export default function ScanCaptureScreen() {
const router = useRouter();
const [scanning, setScanning] = useState(false);
return (
<View style={styles.container}>
<Text style={styles.heading}>Document Scan</Text>
<Text style={styles.hint}>Use your camera to scan a multi-page document. Text will be extracted via OCR.</Text>
<TouchableOpacity
style={[styles.scanBtn, scanning && styles.scanBtnActive]}
onPress={() => setScanning(!scanning)}
accessibilityLabel={scanning ? 'Stop scanning' : 'Start scanning'}
>
<Text style={styles.scanIcon}>{scanning ? '⏹' : '📄'}</Text>
<Text style={styles.scanLabel}>{scanning ? 'Stop' : 'Scan Document'}</Text>
</TouchableOpacity>
<Text style={styles.placeholder}>
Camera-based document scanning will be available in a future release. For now, use the Text or URL capture modes.
</Text>
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()}>
<Text style={styles.backBtnText}>Go Back</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgCanvas, padding: 16, gap: 12 },
heading: { fontSize: 18, fontWeight: '700', color: colors.textPrimary },
hint: { fontSize: 13, color: colors.textSecondary },
scanBtn: { alignSelf: 'center', alignItems: 'center', padding: 24, borderRadius: 60, backgroundColor: colors.surfaceCard, borderWidth: 2, borderColor: colors.borderDefault },
scanBtnActive: { borderColor: colors.accentPrimary, backgroundColor: colors.surfaceMuted },
scanIcon: { fontSize: 40 },
scanLabel: { fontSize: 13, color: colors.textPrimary, marginTop: 4 },
placeholder: { fontSize: 14, color: colors.textTertiary, textAlign: 'center', paddingHorizontal: 20, lineHeight: 20 },
backBtn: { backgroundColor: colors.surfaceMuted, borderRadius: 8, paddingVertical: 8, paddingHorizontal: 16, alignItems: 'center' },
backBtnText: { color: colors.textPrimary, fontWeight: '600', fontSize: 15 },
});

View File

@ -0,0 +1,76 @@
import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
import { useRouter } from 'expo-router';
import { extractFromUrl } from '../../../api/note-prompts';
import { colors } from '../../../theme';
export default function UrlCaptureScreen() {
const router = useRouter();
const [url, setUrl] = useState('');
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<{ title: string; content: string } | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleExtract() {
if (!url.trim()) return;
setBusy(true);
setError(null);
try {
const res = await extractFromUrl(url.trim(), 'default');
setResult({ title: res.title, content: res.content });
} catch (err) {
setError(err instanceof Error ? err.message : 'Extraction failed');
} finally {
setBusy(false);
}
}
return (
<View style={styles.container}>
<Text style={styles.heading}>URL Capture</Text>
<Text style={styles.hint}>Paste a URL to extract and summarize its content.</Text>
<TextInput
style={styles.input}
placeholder="https://example.com/article"
placeholderTextColor={colors.textTertiary}
value={url}
onChangeText={setUrl}
autoCapitalize="none"
keyboardType="url"
accessibilityLabel="URL input"
/>
<TouchableOpacity
style={[styles.btn, busy && { opacity: 0.6 }]}
disabled={busy || !url.trim()}
onPress={() => void handleExtract()}
accessibilityLabel="Extract content"
>
{busy ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.btnText}>Extract</Text>}
</TouchableOpacity>
{error && <Text style={styles.error}>{error}</Text>}
{result && (
<View style={styles.resultCard}>
<Text style={styles.resultTitle}>{result.title}</Text>
<Text style={styles.resultContent} numberOfLines={20}>{result.content}</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgCanvas, padding: 16, gap: 12 },
heading: { fontSize: 18, fontWeight: '700', color: colors.textPrimary },
hint: { fontSize: 13, color: colors.textSecondary },
input: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.borderDefault, color: colors.textPrimary, fontSize: 15 },
btn: { backgroundColor: colors.accentPrimary, borderRadius: 8, paddingVertical: 10, alignItems: 'center' },
btnText: { color: '#fff', fontWeight: '600', fontSize: 15 },
error: { color: colors.danger, fontSize: 13 },
resultCard: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.borderDefault, gap: 8 },
resultTitle: { fontSize: 16, fontWeight: '600', color: colors.textPrimary },
resultContent: { fontSize: 14, color: colors.textSecondary, lineHeight: 20 },
});

View File

@ -0,0 +1,51 @@
import { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { colors } from '../../../theme';
export default function VoiceCaptureScreen() {
const router = useRouter();
const [recording, setRecording] = useState(false);
const [transcript, setTranscript] = useState('');
return (
<View style={styles.container}>
<Text style={styles.heading}>Voice Capture</Text>
<Text style={styles.hint}>Tap the microphone to start recording. Speech will be transcribed automatically.</Text>
<TouchableOpacity
style={[styles.micBtn, recording && styles.micBtnActive]}
onPress={() => setRecording(!recording)}
accessibilityLabel={recording ? 'Stop recording' : 'Start recording'}
>
<Text style={styles.micIcon}>{recording ? '⏹' : '🎙'}</Text>
<Text style={styles.micLabel}>{recording ? 'Stop' : 'Record'}</Text>
</TouchableOpacity>
{transcript.length > 0 && (
<>
<View style={styles.transcriptBox}>
<Text style={styles.transcriptText}>{transcript}</Text>
</View>
<TouchableOpacity style={styles.saveBtn} onPress={() => router.back()}>
<Text style={styles.saveBtnText}>Save as Note</Text>
</TouchableOpacity>
</>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgCanvas, padding: 16, gap: 12 },
heading: { fontSize: 18, fontWeight: '700', color: colors.textPrimary },
hint: { fontSize: 13, color: colors.textSecondary },
micBtn: { alignSelf: 'center', alignItems: 'center', padding: 24, borderRadius: 60, backgroundColor: colors.surfaceCard, borderWidth: 2, borderColor: colors.borderDefault },
micBtnActive: { borderColor: colors.accentPrimary, backgroundColor: colors.surfaceMuted },
micIcon: { fontSize: 40 },
micLabel: { fontSize: 13, color: colors.textPrimary, marginTop: 4 },
transcriptBox: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.borderDefault },
transcriptText: { fontSize: 15, color: colors.textPrimary, lineHeight: 22 },
saveBtn: { backgroundColor: colors.accentPrimary, borderRadius: 8, paddingVertical: 8, paddingHorizontal: 16, alignItems: 'center' },
saveBtnText: { color: '#fff', fontWeight: '600', fontSize: 15 },
});

View File

@ -0,0 +1,130 @@
import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { usePromptStore } from '../store/prompt-store';
import { colors } from '../theme';
export default function PromptResultScreen() {
const router = useRouter();
const { templateName } = useLocalSearchParams<{ templateName?: string }>();
const { lastResult, clearResult } = usePromptStore();
if (!lastResult) {
return (
<View style={styles.container}>
<Text style={styles.empty}>No prompt result available.</Text>
<TouchableOpacity style={styles.btn} onPress={() => router.back()}>
<Text style={styles.btnText}>Go Back</Text>
</TouchableOpacity>
</View>
);
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.heading}>{templateName ?? 'Prompt Result'}</Text>
{lastResult.model && (
<Text style={styles.meta}>
Model: {lastResult.model}
{lastResult.usage ? ` · ${lastResult.usage.totalTokens} tokens` : ''}
</Text>
)}
<View style={styles.resultCard}>
<Text style={styles.resultText}>{lastResult.content}</Text>
</View>
<View style={styles.badges}>
<View style={styles.badge}>
<Text style={styles.badgeText}>{lastResult.templateSlug}</Text>
</View>
<View style={styles.badge}>
<Text style={styles.badgeText}>{lastResult.outputType}</Text>
</View>
</View>
<View style={styles.actions}>
<TouchableOpacity
style={styles.btn}
onPress={() => {
clearResult();
router.back();
}}
>
<Text style={styles.btnText}>Dismiss</Text>
</TouchableOpacity>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgCanvas,
},
content: {
padding: 16,
gap: 12,
},
heading: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
},
meta: {
fontSize: 13,
color: colors.textSecondary,
},
resultCard: {
backgroundColor: colors.surfaceCard,
borderRadius: 8,
padding: 12,
borderWidth: 1,
borderColor: colors.borderDefault,
},
resultText: {
fontSize: 15,
color: colors.textPrimary,
lineHeight: 22,
},
badges: {
flexDirection: 'row',
gap: 8,
},
badge: {
backgroundColor: colors.surfaceMuted,
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 2,
borderWidth: 1,
borderColor: colors.borderDefault,
},
badgeText: {
fontSize: 12,
color: colors.textSecondary,
},
actions: {
flexDirection: 'row',
gap: 8,
marginTop: 12,
},
btn: {
backgroundColor: colors.accentPrimary,
borderRadius: 8,
paddingVertical: 8,
paddingHorizontal: 16,
alignItems: 'center',
},
btnText: {
color: '#fff',
fontWeight: '600',
fontSize: 15,
},
empty: {
fontSize: 15,
color: colors.textSecondary,
textAlign: 'center',
marginTop: 24,
},
});

View File

@ -29,6 +29,13 @@ export function KeyboardShortcuts() {
handler: () => router.push("/reviews"),
description: "Go to reviews",
},
{
key: "a",
meta: true,
shift: true,
handler: () => router.push("/prompts"),
description: "Open Smart Actions",
},
{
key: "Escape",
handler: () => {