From 6095f1d98596e331287e56863374d0d46af5e0c7 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Mon, 6 Apr 2026 13:43:47 -0700 Subject: [PATCH] feat(smart-actions): add prompt-result screen, capture sub-routes, Cmd+Shift+A shortcut, telemetry events (G16-G19) --- backend/src/lib/note-hooks.ts | 2 + backend/src/modules/note-prompts/routes.ts | 6 +- backend/src/modules/notes/routes.ts | 4 + mobile/src/app/(tabs)/capture/scan.tsx | 46 ++++++++ mobile/src/app/(tabs)/capture/url.tsx | 76 ++++++++++++ mobile/src/app/(tabs)/capture/voice.tsx | 51 ++++++++ mobile/src/app/prompt-result.tsx | 130 +++++++++++++++++++++ web/src/components/KeyboardShortcuts.tsx | 7 ++ 8 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 mobile/src/app/(tabs)/capture/scan.tsx create mode 100644 mobile/src/app/(tabs)/capture/url.tsx create mode 100644 mobile/src/app/(tabs)/capture/voice.tsx create mode 100644 mobile/src/app/prompt-result.tsx diff --git a/backend/src/lib/note-hooks.ts b/backend/src/lib/note-hooks.ts index e859c4e..b895cea 100644 --- a/backend/src/lib/note-hooks.ts +++ b/backend/src/lib/note-hooks.ts @@ -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'); diff --git a/backend/src/modules/note-prompts/routes.ts b/backend/src/modules/note-prompts/routes.ts index 921d681..f5ff99e 100644 --- a/backend/src/modules/note-prompts/routes.ts +++ b/backend/src/modules/note-prompts/routes.ts @@ -296,7 +296,11 @@ export async function notePromptRoutes(app: FastifyInstance): Promise { } 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) ────────────────────────── diff --git a/backend/src/modules/notes/routes.ts b/backend/src/modules/notes/routes.ts index d520268..422fdb5 100644 --- a/backend/src/modules/notes/routes.ts +++ b/backend/src/modules/notes/routes.ts @@ -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; }); diff --git a/mobile/src/app/(tabs)/capture/scan.tsx b/mobile/src/app/(tabs)/capture/scan.tsx new file mode 100644 index 0000000..186ca28 --- /dev/null +++ b/mobile/src/app/(tabs)/capture/scan.tsx @@ -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 ( + + Document Scan + Use your camera to scan a multi-page document. Text will be extracted via OCR. + + setScanning(!scanning)} + accessibilityLabel={scanning ? 'Stop scanning' : 'Start scanning'} + > + {scanning ? '⏹' : '📄'} + {scanning ? 'Stop' : 'Scan Document'} + + + + Camera-based document scanning will be available in a future release. For now, use the Text or URL capture modes. + + + router.back()}> + Go Back + + + ); +} + +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 }, +}); diff --git a/mobile/src/app/(tabs)/capture/url.tsx b/mobile/src/app/(tabs)/capture/url.tsx new file mode 100644 index 0000000..15dcfe4 --- /dev/null +++ b/mobile/src/app/(tabs)/capture/url.tsx @@ -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(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 ( + + URL Capture + Paste a URL to extract and summarize its content. + + + + void handleExtract()} + accessibilityLabel="Extract content" + > + {busy ? : Extract} + + + {error && {error}} + + {result && ( + + {result.title} + {result.content} + + )} + + ); +} + +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 }, +}); diff --git a/mobile/src/app/(tabs)/capture/voice.tsx b/mobile/src/app/(tabs)/capture/voice.tsx new file mode 100644 index 0000000..88fc92f --- /dev/null +++ b/mobile/src/app/(tabs)/capture/voice.tsx @@ -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 ( + + Voice Capture + Tap the microphone to start recording. Speech will be transcribed automatically. + + setRecording(!recording)} + accessibilityLabel={recording ? 'Stop recording' : 'Start recording'} + > + {recording ? '⏹' : '🎙'} + {recording ? 'Stop' : 'Record'} + + + {transcript.length > 0 && ( + <> + + {transcript} + + router.back()}> + Save as Note + + + )} + + ); +} + +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 }, +}); diff --git a/mobile/src/app/prompt-result.tsx b/mobile/src/app/prompt-result.tsx new file mode 100644 index 0000000..53ec5f6 --- /dev/null +++ b/mobile/src/app/prompt-result.tsx @@ -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 ( + + No prompt result available. + router.back()}> + Go Back + + + ); + } + + return ( + + {templateName ?? 'Prompt Result'} + + {lastResult.model && ( + + Model: {lastResult.model} + {lastResult.usage ? ` · ${lastResult.usage.totalTokens} tokens` : ''} + + )} + + + {lastResult.content} + + + + + {lastResult.templateSlug} + + + {lastResult.outputType} + + + + + { + clearResult(); + router.back(); + }} + > + Dismiss + + + + ); +} + +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, + }, +}); diff --git a/web/src/components/KeyboardShortcuts.tsx b/web/src/components/KeyboardShortcuts.tsx index e365d52..621e88e 100644 --- a/web/src/components/KeyboardShortcuts.tsx +++ b/web/src/components/KeyboardShortcuts.tsx @@ -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: () => {