feat: quick capture sends to chat instead of quick-capture endpoint

Replace POST /api/quick-capture with: create a conversation, send the
message, and fire-and-forget the SSE generation stream so the assistant
processes the request in the background without blocking the UI.
The new conversation appears immediately in the chat tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 19:00:33 -04:00
parent 946b70ecc4
commit 4ebc57d2e5
2 changed files with 19 additions and 85 deletions
+1 -48
View File
@@ -5,21 +5,17 @@ import 'package:go_router/go_router.dart';
import 'package:flutter_timezone/flutter_timezone.dart'; import 'package:flutter_timezone/flutter_timezone.dart';
import 'core/constants.dart'; import 'core/constants.dart';
import 'core/exceptions.dart';
import 'core/theme.dart'; import 'core/theme.dart';
import 'providers/api_client_provider.dart'; import 'providers/api_client_provider.dart';
import 'providers/auth_provider.dart'; import 'providers/auth_provider.dart';
import 'providers/capture_queue_provider.dart';
import 'providers/capture_work_queue_provider.dart'; import 'providers/capture_work_queue_provider.dart';
import 'providers/briefing_provider.dart'; import 'providers/briefing_provider.dart';
import 'providers/calendar_provider.dart'; import 'providers/calendar_provider.dart';
import 'providers/chat_provider.dart'; import 'providers/chat_provider.dart';
import 'providers/knowledge_provider.dart'; import 'providers/knowledge_provider.dart';
import 'providers/news_provider.dart'; import 'providers/news_provider.dart';
import 'providers/notes_provider.dart';
import 'providers/settings_provider.dart'; import 'providers/settings_provider.dart';
import 'providers/update_provider.dart'; import 'providers/update_provider.dart';
import 'providers/tasks_provider.dart';
import 'screens/auth/login_screen.dart'; import 'screens/auth/login_screen.dart';
import 'screens/briefing/briefing_screen.dart'; import 'screens/briefing/briefing_screen.dart';
import 'screens/knowledge/knowledge_screen.dart'; import 'screens/knowledge/knowledge_screen.dart';
@@ -516,12 +512,6 @@ class _QuickCaptureBar extends ConsumerStatefulWidget {
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
final _controller = TextEditingController(); final _controller = TextEditingController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
}
@override @override
void dispose() { void dispose() {
_controller.dispose(); _controller.dispose();
@@ -537,36 +527,6 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
ref.read(captureWorkQueueProvider.notifier).enqueue(text); ref.read(captureWorkQueueProvider.notifier).enqueue(text);
} }
Future<void> _drainOfflineQueue() async {
if (!mounted) return;
final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return;
final api = ref.read(quickCaptureApiProvider);
for (final text in List<String>.from(queue)) {
if (!mounted) break;
try {
final result = await api.capture(text);
// Dequeue before the mounted check — SharedPreferences doesn't need
// the widget alive, and skipping this would leave a ghost item.
await ref.read(captureQueueProvider.notifier).dequeue(text);
if (!mounted) break;
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
}
} on NetworkException {
break;
} catch (_) {
// Server error or unexpected failure — drop from queue to prevent
// ghost items that can never be cleared.
await ref.read(captureQueueProvider.notifier).dequeue(text);
}
}
}
Future<void> _toggleCaptureMic() async { Future<void> _toggleCaptureMic() async {
final voice = ref.read(voiceProvider); final voice = ref.read(voiceProvider);
if (voice.voiceModeActive) { if (voice.voiceModeActive) {
@@ -596,10 +556,8 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation; final location = GoRouterState.of(context).matchedLocation;
final offlineQueueCount = ref.watch(captureQueueProvider).length;
final workQueue = ref.watch(captureWorkQueueProvider); final workQueue = ref.watch(captureWorkQueueProvider);
final isWorking = workQueue.isNotEmpty; final isWorking = workQueue.isNotEmpty;
final totalPending = workQueue.length + offlineQueueCount;
// Show snackbar when a result is published. // Show snackbar when a result is published.
ref.listen(captureResultProvider, (_, result) { ref.listen(captureResultProvider, (_, result) {
@@ -634,12 +592,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
isDense: true, isDense: true,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10), horizontal: 14, vertical: 10),
prefixIcon: totalPending > 0 prefixIcon: isWorking
? Badge(
label: Text('$totalPending'),
child: const Icon(Icons.cloud_upload_outlined),
)
: isWorking
? const Padding( ? const Padding(
padding: EdgeInsets.all(12), padding: EdgeInsets.all(12),
child: SizedBox( child: SizedBox(
+18 -37
View File
@@ -2,9 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/exceptions.dart'; import '../core/exceptions.dart';
import 'api_client_provider.dart'; import 'api_client_provider.dart';
import 'capture_queue_provider.dart'; import 'chat_provider.dart';
import 'notes_provider.dart';
import 'tasks_provider.dart';
/// Outcome of a single capture attempt — consumed by the UI for snackbars. /// Outcome of a single capture attempt — consumed by the UI for snackbars.
class CaptureResult { class CaptureResult {
@@ -25,7 +23,6 @@ class _CaptureResultNotifier extends Notifier<CaptureResult?> {
} }
/// In-memory sequential work queue for quick captures. /// In-memory sequential work queue for quick captures.
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
final captureWorkQueueProvider = final captureWorkQueueProvider =
NotifierProvider<CaptureWorkQueueNotifier, List<String>>( NotifierProvider<CaptureWorkQueueNotifier, List<String>>(
CaptureWorkQueueNotifier.new, CaptureWorkQueueNotifier.new,
@@ -52,54 +49,38 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
// Signal "no result yet" so the same result value can re-trigger watch. // Signal "no result yet" so the same result value can re-trigger watch.
ref.read(captureResultProvider.notifier).state = null; ref.read(captureResultProvider.notifier).state = null;
try { try {
final api = ref.read(quickCaptureApiProvider); // Create a new conversation, add it to the conversations list, then
final result = await api.capture(text); // send the message and kick off generation in the background.
final conv =
await ref.read(conversationsProvider.notifier).create('');
final chatRepo = ref.read(chatRepositoryProvider);
await chatRepo.sendMessage(conv.id, text);
// Fire-and-forget: drain the SSE stream so the server generates a
// response (creating notes/tasks/etc.) without blocking the UI.
chatRepo.streamGeneration(conv.id).drain<void>().ignore();
// Dequeue on success.
state = state.length > 1 ? state.sublist(1) : []; state = state.length > 1 ? state.sublist(1) : [];
ref.read(captureResultProvider.notifier).state =
// Invalidate content providers so lists refresh. const CaptureResult('Sent to Fabled.');
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
}
// Publish result for snackbar.
final msg = result.message.isNotEmpty
? result.message
: '${_typeLabel(result.type)} created: ${result.title}';
ref.read(captureResultProvider.notifier).state = CaptureResult(msg);
} on NetworkException catch (_) { } on NetworkException catch (_) {
// Persist to offline queue and stop draining — still offline.
await ref.read(captureQueueProvider.notifier).enqueue(text);
state = state.length > 1 ? state.sublist(1) : []; state = state.length > 1 ? state.sublist(1) : [];
ref.read(captureResultProvider.notifier).state = CaptureResult( ref.read(captureResultProvider.notifier).state = const CaptureResult(
"You're offline — capture saved and will retry automatically.", "You're offline — message not sent.",
isError: true,
); );
break;
} on AppException catch (e) { } on AppException catch (e) {
state = state.length > 1 ? state.sublist(1) : []; state = state.length > 1 ? state.sublist(1) : [];
ref.read(captureResultProvider.notifier).state = ref.read(captureResultProvider.notifier).state =
CaptureResult(e.message, isError: true); CaptureResult(e.message, isError: true);
} catch (_) { } catch (_) {
state = state.length > 1 ? state.sublist(1) : []; state = state.length > 1 ? state.sublist(1) : [];
ref.read(captureResultProvider.notifier).state = ref.read(captureResultProvider.notifier).state = const CaptureResult(
CaptureResult('Capture failed. Please try again.', isError: true); 'Failed to send. Please try again.',
isError: true);
} }
} }
} finally { } finally {
_running = false; _running = false;
} }
} }
String _typeLabel(String type) => switch (type) {
'note' => 'Note',
'task' => 'Task',
'event' => 'Event',
'todo' => 'To-do',
_ => type,
};
} }