Matthias Nott
2026-07-10 1d79dcb975ae6f606a783a3d0287d8dc473e1cc8
feat: session launcher (all-project search + switch-topic) and persistent last-host fast reconnect
3 files modified
2 files added
changed files
lib/models/project.dart patch | view | blame | history
lib/providers/providers.dart patch | view | blame | history
lib/screens/chat_screen.dart patch | view | blame | history
lib/services/mqtt_service.dart patch | view | blame | history
lib/widgets/new_session_sheet.dart patch | view | blame | history
lib/models/project.dart
....@@ -0,0 +1,35 @@
1
+/// A PAI project the user can launch a session in, as sent by the hub's
2
+/// `projects` control response.
3
+class Project {
4
+ /// Display name shown in the picker (e.g. "AIBroker").
5
+ final String name;
6
+
7
+ /// Canonical name the hub uses to launch the project (create {project}).
8
+ final String launch;
9
+
10
+ final String slug;
11
+ final String path;
12
+ final int sessions;
13
+ final String lastActive; // ISO8601 (sorts lexicographically)
14
+
15
+ const Project({
16
+ required this.name,
17
+ required this.launch,
18
+ this.slug = '',
19
+ this.path = '',
20
+ this.sessions = 0,
21
+ this.lastActive = '',
22
+ });
23
+
24
+ factory Project.fromJson(Map<String, dynamic> json) {
25
+ final display = json['name'] as String? ?? 'Project';
26
+ return Project(
27
+ name: display,
28
+ launch: json['launch'] as String? ?? display,
29
+ slug: json['slug'] as String? ?? '',
30
+ path: json['path'] as String? ?? '',
31
+ sessions: json['sessions'] as int? ?? 0,
32
+ lastActive: json['lastActive'] as String? ?? '',
33
+ );
34
+ }
35
+}
lib/providers/providers.dart
....@@ -6,6 +6,7 @@
66 import 'package:flutter_secure_storage/flutter_secure_storage.dart';
77
88 import '../models/message.dart';
9
+import '../models/project.dart';
910 import '../models/server_config.dart';
1011 import '../models/session.dart';
1112 import '../services/message_store.dart';
....@@ -25,8 +26,8 @@
2526
2627 final serverConfigProvider =
2728 StateNotifierProvider<ServerConfigNotifier, ServerConfig?>((ref) {
28
- return ServerConfigNotifier();
29
-});
29
+ return ServerConfigNotifier();
30
+ });
3031
3132 class ServerConfigNotifier extends StateNotifier<ServerConfig?> {
3233 ServerConfigNotifier() : super(null) {
....@@ -60,8 +61,9 @@
6061
6162 // --- Connection Status ---
6263
63
-final wsStatusProvider =
64
- StateProvider<ConnectionStatus>((ref) => ConnectionStatus.disconnected);
64
+final wsStatusProvider = StateProvider<ConnectionStatus>(
65
+ (ref) => ConnectionStatus.disconnected,
66
+);
6567
6668 final connectionDetailProvider = StateProvider<String>((ref) => '');
6769 final connectedViaProvider = StateProvider<String>((ref) => '');
....@@ -83,12 +85,17 @@
8385 }
8486 });
8587
88
+// --- Projects (PAI project launcher) ---
89
+
90
+final projectsProvider = StateProvider<List<Project>>((ref) => []);
91
+
8692 // --- Messages ---
8793
88
-final messagesProvider =
89
- StateNotifierProvider<MessagesNotifier, List<Message>>((ref) {
90
- return MessagesNotifier(ref);
91
-});
94
+final messagesProvider = StateNotifierProvider<MessagesNotifier, List<Message>>(
95
+ (ref) {
96
+ return MessagesNotifier(ref);
97
+ },
98
+);
9299
93100 class MessagesNotifier extends StateNotifier<List<Message>> {
94101 MessagesNotifier(this.ref) : super([]);
....@@ -103,7 +110,9 @@
103110 void switchSession(String sessionId) {
104111 if (_currentSessionId == sessionId) {
105112 TraceService.instance.addTrace(
106
- 'switchSession SKIP', 'already on ${sessionId.substring(0, 8)}');
113
+ 'switchSession SKIP',
114
+ 'already on ${sessionId.substring(0, 8)}',
115
+ );
107116 return;
108117 }
109118 TraceService.instance.addTrace(
....@@ -173,8 +182,7 @@
173182
174183 // --- Unread Counts ---
175184
176
-final unreadCountsProvider =
177
- StateProvider<Map<String, int>>((ref) => {});
185
+final unreadCountsProvider = StateProvider<Map<String, int>>((ref) => {});
178186
179187 // --- Input Mode ---
180188
....@@ -187,7 +195,9 @@
187195 // Holds the bridge between NavigateScreen and ChatScreen's MQTT service.
188196 // ChatScreen sets this when MQTT is initialized; NavigateScreen reads it.
189197 // Using a Riverpod provider eliminates the stale static reference risk.
190
-final navigateNotifierProvider = StateProvider<NavigateNotifier?>((ref) => null);
198
+final navigateNotifierProvider = StateProvider<NavigateNotifier?>(
199
+ (ref) => null,
200
+);
191201
192202 // --- Pro / Purchase Status ---
193203
lib/screens/chat_screen.dart
....@@ -13,6 +13,7 @@
1313 import 'package:shared_preferences/shared_preferences.dart';
1414
1515 import '../models/message.dart';
16
+import '../models/project.dart';
1617 import '../models/session.dart';
1718 // ignore: unused_import
1819 import '../models/server_config.dart';
....@@ -30,6 +31,7 @@
3031 import '../widgets/message_bubble.dart';
3132 import '../widgets/paywall_banner.dart';
3233 import '../widgets/session_drawer.dart';
34
+import '../widgets/new_session_sheet.dart';
3335 import '../widgets/status_dot.dart';
3436 import '../widgets/toast_overlay.dart';
3537 import '../widgets/typing_indicator.dart';
....@@ -100,8 +102,9 @@
100102 final savedUnreads = prefs.getString('unreadCounts');
101103 if (savedUnreads != null && mounted) {
102104 try {
103
- final map = (jsonDecode(savedUnreads) as Map<String, dynamic>)
104
- .map((k, v) => MapEntry(k, v as int));
105
+ final map = (jsonDecode(savedUnreads) as Map<String, dynamic>).map(
106
+ (k, v) => MapEntry(k, v as int),
107
+ );
105108 ref.read(unreadCountsProvider.notifier).state = map;
106109 } catch (_) {}
107110 }
....@@ -221,7 +224,8 @@
221224 ref.read(wsStatusProvider.notifier).state = status;
222225 if (status == ConnectionStatus.connected) {
223226 ref.read(connectionDetailProvider.notifier).state = '';
224
- ref.read(connectedViaProvider.notifier).state = _ws?.connectedVia ?? '';
227
+ ref.read(connectedViaProvider.notifier).state =
228
+ _ws?.connectedVia ?? '';
225229 } else {
226230 ref.read(connectedViaProvider.notifier).state = '';
227231 }
....@@ -243,7 +247,10 @@
243247 Future.delayed(const Duration(milliseconds: 200), () {
244248 if (!mounted) return;
245249 final activeId = ref.read(activeSessionIdProvider);
246
- _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null);
250
+ _sendCommand(
251
+ 'sync',
252
+ activeId != null ? {'activeSessionId': activeId} : null,
253
+ );
247254 _push?.onMqttConnected();
248255 });
249256 };
....@@ -270,7 +277,9 @@
270277 _sendCommand('nav', {'key': key});
271278 },
272279 requestScreenshot: (sessionId) {
273
- _sendCommand('screenshot', {'sessionId': sessionId ?? ref.read(activeSessionIdProvider)});
280
+ _sendCommand('screenshot', {
281
+ 'sessionId': sessionId ?? ref.read(activeSessionIdProvider),
282
+ });
274283 },
275284 );
276285
....@@ -332,6 +341,8 @@
332341 switch (type) {
333342 case 'sessions':
334343 _handleSessions(msg);
344
+ case 'projects':
345
+ _handleProjects(msg);
335346 case 'message':
336347 case 'text':
337348 _handleIncomingMessage(msg);
....@@ -340,10 +351,16 @@
340351 case 'image':
341352 _handleIncomingImage(msg);
342353 case 'typing':
343
- final typing = msg['typing'] as bool? ?? msg['isTyping'] as bool? ?? msg['active'] as bool? ?? true;
354
+ final typing =
355
+ msg['typing'] as bool? ??
356
+ msg['isTyping'] as bool? ??
357
+ msg['active'] as bool? ??
358
+ true;
344359 final typingSession = msg['sessionId'] as String?;
345360 final activeId = ref.read(activeSessionIdProvider);
346
- _chatLog('TYPING: session=${typingSession?.substring(0, 8)} active=${activeId?.substring(0, 8)} typing=$typing match=${typingSession == activeId}');
361
+ _chatLog(
362
+ 'TYPING: session=${typingSession?.substring(0, 8)} active=${activeId?.substring(0, 8)} typing=$typing match=${typingSession == activeId}',
363
+ );
347364 // Strict: only show typing for the ACTIVE session, ignore all others
348365 if (activeId != null && typingSession == activeId) {
349366 ref.read(isTypingProvider.notifier).state = typing;
....@@ -369,6 +386,16 @@
369386 case 'clear':
370387 ref.read(messagesProvider.notifier).clearMessages();
371388 case 'session_switched':
389
+ // The hub switched/created a session (e.g. from the launcher) — follow it
390
+ // so tapping a project actually opens that session in the app.
391
+ final switchedId = msg['sessionId'] as String?;
392
+ if (switchedId != null && switchedId.isNotEmpty) {
393
+ ref.read(activeSessionIdProvider.notifier).state = switchedId;
394
+ ref.read(messagesProvider.notifier).switchSession(switchedId);
395
+ SharedPreferences.getInstance().then(
396
+ (p) => p.setString('activeSessionId', switchedId),
397
+ );
398
+ }
372399 _sendCommand('sessions');
373400 case 'session_renamed':
374401 _sendCommand('sessions');
....@@ -380,7 +407,9 @@
380407 final currentMessages = ref.read(messagesProvider);
381408 final inCurrent = currentMessages.any((m) => m.id == messageId);
382409 if (inCurrent) {
383
- ref.read(messagesProvider.notifier).updateContent(messageId, content);
410
+ ref
411
+ .read(messagesProvider.notifier)
412
+ .updateContent(messageId, content);
384413 } else {
385414 // Message is in a different session (user switched after recording).
386415 // Load that session's messages from disk, update, and save back.
....@@ -404,7 +433,9 @@
404433 if (catchUpMsgs != null && catchUpMsgs.isNotEmpty) {
405434 _isCatchingUp = true;
406435 final activeId = ref.read(activeSessionIdProvider);
407
- final currentId = ref.read(messagesProvider.notifier).currentSessionId;
436
+ final currentId = ref
437
+ .read(messagesProvider.notifier)
438
+ .currentSessionId;
408439 final existing = ref.read(messagesProvider);
409440 final existingContents = existing
410441 .where((m) => m.role == MessageRole.assistant)
....@@ -418,14 +449,21 @@
418449 for (final m in catchUpMsgs) {
419450 final map = m as Map<String, dynamic>;
420451 final msgType = map['type'] as String? ?? 'text';
421
- final content = map['content'] as String? ?? map['transcript'] as String? ?? map['caption'] as String? ?? '';
452
+ final content =
453
+ map['content'] as String? ??
454
+ map['transcript'] as String? ??
455
+ map['caption'] as String? ??
456
+ '';
422457 final msgSessionId = map['sessionId'] as String?;
423458 final imageData = map['imageBase64'] as String?;
424459
425460 // Skip empty text messages (images with no caption are OK)
426461 if (content.isEmpty && imageData == null) continue;
427462 // Dedup by content (skip images from dedup — they have unique msgIds)
428
- if (imageData == null && content.isNotEmpty && existingContents.contains(content)) continue;
463
+ if (imageData == null &&
464
+ content.isNotEmpty &&
465
+ existingContents.contains(content))
466
+ continue;
429467
430468 final Message message;
431469 if (msgType == 'image' && imageData != null) {
....@@ -444,7 +482,9 @@
444482 );
445483 }
446484
447
- _chatLog('catch_up msg: session=${msgSessionId?.substring(0, 8) ?? "NULL"} active=${activeId?.substring(0, 8)} content="${content.substring(0, content.length.clamp(0, 40))}"');
485
+ _chatLog(
486
+ 'catch_up msg: session=${msgSessionId?.substring(0, 8) ?? "NULL"} active=${activeId?.substring(0, 8)} content="${content.substring(0, content.length.clamp(0, 40))}"',
487
+ );
448488
449489 if (msgSessionId == null || msgSessionId == currentId) {
450490 // Active session or no session: add to UI (addMessage also appends to log).
....@@ -453,7 +493,8 @@
453493 // Cross-session: synchronous append — no race condition.
454494 MessageStoreV2.append(msgSessionId, message);
455495 _incrementUnread(msgSessionId);
456
- crossSessionCounts[msgSessionId] = (crossSessionCounts[msgSessionId] ?? 0) + 1;
496
+ crossSessionCounts[msgSessionId] =
497
+ (crossSessionCounts[msgSessionId] ?? 0) + 1;
457498 crossSessionPreviews.putIfAbsent(msgSessionId, () => content);
458499 }
459500 existingContents.add(content);
....@@ -470,7 +511,8 @@
470511 final count = entry.value;
471512 final session = sessions.firstWhere(
472513 (s) => s.id == sid,
473
- orElse: () => Session(id: sid, index: 0, name: 'Unknown', type: 'claude'),
514
+ orElse: () =>
515
+ Session(id: sid, index: 0, name: 'Unknown', type: 'claude'),
474516 );
475517 final preview = count == 1
476518 ? (crossSessionPreviews[sid] ?? '')
....@@ -478,7 +520,9 @@
478520 ToastManager.show(
479521 context,
480522 sessionName: session.name,
481
- preview: preview.length > 100 ? '${preview.substring(0, 100)}...' : preview,
523
+ preview: preview.length > 100
524
+ ? '${preview.substring(0, 100)}...'
525
+ : preview,
482526 onTap: () => _switchSession(sid),
483527 );
484528 }
....@@ -486,7 +530,9 @@
486530
487531 // Clear unread for active session
488532 if (activeId != null) {
489
- final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
533
+ final counts = Map<String, int>.from(
534
+ ref.read(unreadCountsProvider),
535
+ );
490536 counts.remove(activeId);
491537 ref.read(unreadCountsProvider.notifier).state = counts;
492538 }
....@@ -523,7 +569,9 @@
523569 ref.read(activeSessionIdProvider.notifier).state = active.id;
524570 // Synchronous session switch — no async gap.
525571 ref.read(messagesProvider.notifier).switchSession(active.id);
526
- SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', active.id));
572
+ SharedPreferences.getInstance().then(
573
+ (p) => p.setString('activeSessionId', active.id),
574
+ );
527575 }
528576
529577 // Session is ready — process any pending messages that arrived before sessions list
....@@ -542,6 +590,14 @@
542590 }
543591 }
544592
593
+ void _handleProjects(Map<String, dynamic> msg) {
594
+ final list = msg['projects'] as List<dynamic>?;
595
+ if (list == null) return;
596
+ ref.read(projectsProvider.notifier).state = list
597
+ .map((p) => Project.fromJson(p as Map<String, dynamic>))
598
+ .toList();
599
+ }
600
+
545601 /// Respond to a pailot_debug_state request from the server.
546602 /// Reads the in-memory session list and active session from providers
547603 /// and publishes exactly what the app is currently rendering.
....@@ -549,14 +605,18 @@
549605 final sessions = ref.read(sessionsProvider);
550606 final activeSessionId = ref.read(activeSessionIdProvider);
551607
552
- final sessionPayloads = sessions.map((s) => {
553
- 'sessionId': s.id,
554
- 'index': s.index,
555
- 'displayedName': s.name, // exactly what is shown in the drawer
556
- 'type': s.type,
557
- if (s.kind != null) 'kind': s.kind,
558
- 'isActive': s.id == activeSessionId,
559
- }).toList();
608
+ final sessionPayloads = sessions
609
+ .map(
610
+ (s) => {
611
+ 'sessionId': s.id,
612
+ 'index': s.index,
613
+ 'displayedName': s.name, // exactly what is shown in the drawer
614
+ 'type': s.type,
615
+ if (s.kind != null) 'kind': s.kind,
616
+ 'isActive': s.id == activeSessionId,
617
+ },
618
+ )
619
+ .toList();
560620
561621 _ws?.publishDebugStateResponse(
562622 requestId: requestId,
....@@ -568,9 +628,7 @@
568628
569629 void _handleIncomingMessage(Map<String, dynamic> msg) {
570630 final sessionId = msg['sessionId'] as String?;
571
- final content = msg['content'] as String? ??
572
- msg['text'] as String? ??
573
- '';
631
+ final content = msg['content'] as String? ?? msg['text'] as String? ?? '';
574632
575633 TraceService.instance.addTrace(
576634 'handleMessage processing type=text',
....@@ -597,13 +655,16 @@
597655 final sessions = ref.read(sessionsProvider);
598656 final session = sessions.firstWhere(
599657 (s) => s.id == sessionId,
600
- orElse: () => Session(id: sessionId, index: 0, name: 'Unknown', type: 'claude'),
658
+ orElse: () =>
659
+ Session(id: sessionId, index: 0, name: 'Unknown', type: 'claude'),
601660 );
602661 if (mounted) {
603662 ToastManager.show(
604663 context,
605664 sessionName: session.name,
606
- preview: content.length > 100 ? '${content.substring(0, 100)}...' : content,
665
+ preview: content.length > 100
666
+ ? '${content.substring(0, 100)}...'
667
+ : content,
607668 onTap: () => _switchSession(sessionId),
608669 );
609670 }
....@@ -620,12 +681,21 @@
620681
621682 Future<void> _handleIncomingVoice(Map<String, dynamic> msg) async {
622683 final sessionId = msg['sessionId'] as String?;
623
- final audioData = msg['audioBase64'] as String? ?? msg['audio'] as String? ?? msg['data'] as String?;
624
- final content = msg['content'] as String? ?? msg['transcript'] as String? ?? msg['text'] as String? ?? '';
684
+ final audioData =
685
+ msg['audioBase64'] as String? ??
686
+ msg['audio'] as String? ??
687
+ msg['data'] as String?;
688
+ final content =
689
+ msg['content'] as String? ??
690
+ msg['transcript'] as String? ??
691
+ msg['text'] as String? ??
692
+ '';
625693 final duration = msg['duration'] as int?;
626694
627695 final message = Message(
628
- id: msg['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(),
696
+ id:
697
+ msg['id'] as String? ??
698
+ DateTime.now().millisecondsSinceEpoch.toString(),
629699 role: MessageRole.assistant,
630700 type: MessageType.voice,
631701 content: content,
....@@ -641,7 +711,9 @@
641711 try {
642712 final dir = await getTemporaryDirectory();
643713 savedAudioPath = '${dir.path}/voice_${message.id}.m4a';
644
- final bytes = base64Decode(audioData.contains(',') ? audioData.split(',').last : audioData);
714
+ final bytes = base64Decode(
715
+ audioData.contains(',') ? audioData.split(',').last : audioData,
716
+ );
645717 await File(savedAudioPath).writeAsBytes(bytes);
646718 } catch (_) {
647719 savedAudioPath = null;
....@@ -660,7 +732,9 @@
660732 );
661733
662734 final currentId = ref.read(messagesProvider.notifier).currentSessionId;
663
- _chatLog('voice: sessionId=$sessionId currentId=$currentId audioPath=$savedAudioPath content="${content.substring(0, content.length.clamp(0, 30))}"');
735
+ _chatLog(
736
+ 'voice: sessionId=$sessionId currentId=$currentId audioPath=$savedAudioPath content="${content.substring(0, content.length.clamp(0, 30))}"',
737
+ );
664738 if (sessionId != null && sessionId != currentId) {
665739 _chatLog('voice: cross-session, appending to store for $sessionId');
666740 // Synchronous append — no async gap, no race condition.
....@@ -670,7 +744,8 @@
670744 final sessions = ref.read(sessionsProvider);
671745 final session = sessions.firstWhere(
672746 (s) => s.id == sessionId,
673
- orElse: () => Session(id: sessionId, index: 0, name: 'Unknown', type: 'claude'),
747
+ orElse: () =>
748
+ Session(id: sessionId, index: 0, name: 'Unknown', type: 'claude'),
674749 );
675750 if (mounted) {
676751 ToastManager.show(
....@@ -687,15 +762,22 @@
687762 ref.read(isTypingProvider.notifier).state = false;
688763 _scrollToBottom();
689764
690
- if (audioData != null && !AudioService.isBackgrounded && !_isCatchingUp && !_isRecording) {
765
+ if (audioData != null &&
766
+ !AudioService.isBackgrounded &&
767
+ !_isCatchingUp &&
768
+ !_isRecording) {
691769 setState(() => _playingMessageId = storedMessage.id);
692770 AudioService.queueBase64(audioData);
693771 }
694772 }
695773
696774 void _handleIncomingImage(Map<String, dynamic> msg) {
697
- final imageData = msg['imageBase64'] as String? ?? msg['data'] as String? ?? msg['image'] as String?;
698
- final content = msg['content'] as String? ?? msg['caption'] as String? ?? '';
775
+ final imageData =
776
+ msg['imageBase64'] as String? ??
777
+ msg['data'] as String? ??
778
+ msg['image'] as String?;
779
+ final content =
780
+ msg['content'] as String? ?? msg['caption'] as String? ?? '';
699781 final sessionId = msg['sessionId'] as String?;
700782
701783 if (imageData == null) return;
....@@ -703,15 +785,20 @@
703785 // Always update the Navigate screen screenshot provider
704786 ref.read(latestScreenshotProvider.notifier).state = imageData;
705787
706
- final isScreenshot = content == 'Screenshot' ||
788
+ final isScreenshot =
789
+ content == 'Screenshot' ||
707790 content == 'Capturing screenshot...' ||
708791 (msg['type'] == 'screenshot');
709792
710793 if (isScreenshot) {
711794 // Remove any "Capturing screenshot..." placeholder text messages
712
- ref.read(messagesProvider.notifier).removeWhere(
713
- (m) => m.role == MessageRole.assistant && m.content == 'Capturing screenshot...',
714
- );
795
+ ref
796
+ .read(messagesProvider.notifier)
797
+ .removeWhere(
798
+ (m) =>
799
+ m.role == MessageRole.assistant &&
800
+ m.content == 'Capturing screenshot...',
801
+ );
715802
716803 // Only add to chat if the Screen button explicitly requested it
717804 if (!_screenshotForChat) {
....@@ -758,7 +845,9 @@
758845 /// in-place edits). The transcript is updated in-memory if the message is
759846 /// in the active session. Cross-session transcript updates are a no-op.
760847 Future<void> _updateTranscriptOnDisk(String messageId, String content) async {
761
- _chatLog('transcript: cross-session update for messageId=$messageId — in-memory only (append-only log)');
848
+ _chatLog(
849
+ 'transcript: cross-session update for messageId=$messageId — in-memory only (append-only log)',
850
+ );
762851 }
763852
764853 void _incrementUnread(String sessionId) {
....@@ -789,7 +878,9 @@
789878 ref.read(activeSessionIdProvider.notifier).state = sessionId;
790879 // Synchronous — no async gap between session switch and incoming messages.
791880 ref.read(messagesProvider.notifier).switchSession(sessionId);
792
- SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', sessionId));
881
+ SharedPreferences.getInstance().then(
882
+ (p) => p.setString('activeSessionId', sessionId),
883
+ );
793884
794885 final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
795886 counts.remove(sessionId);
....@@ -1023,21 +1114,29 @@
10231114 final mime = att['mimeType'] as String;
10241115 final name = att['fileName'] as String? ?? 'file';
10251116 if (mime.startsWith('image/')) {
1026
- ref.read(messagesProvider.notifier).addMessage(Message.image(
1027
- role: MessageRole.user,
1028
- imageBase64: att['data'] as String,
1029
- content: name,
1030
- status: MessageStatus.sent,
1031
- ));
1117
+ ref
1118
+ .read(messagesProvider.notifier)
1119
+ .addMessage(
1120
+ Message.image(
1121
+ role: MessageRole.user,
1122
+ imageBase64: att['data'] as String,
1123
+ content: name,
1124
+ status: MessageStatus.sent,
1125
+ ),
1126
+ );
10321127 } else {
10331128 final size = base64Decode(att['data'] as String).length;
1034
- ref.read(messagesProvider.notifier).addMessage(Message.text(
1035
- role: MessageRole.user,
1036
- content: textCaption.isNotEmpty
1037
- ? '$textCaption\n📎 $name (${_formatSize(size)})'
1038
- : '📎 $name (${_formatSize(size)})',
1039
- status: MessageStatus.sent,
1040
- ));
1129
+ ref
1130
+ .read(messagesProvider.notifier)
1131
+ .addMessage(
1132
+ Message.text(
1133
+ role: MessageRole.user,
1134
+ content: textCaption.isNotEmpty
1135
+ ? '$textCaption\n📎 $name (${_formatSize(size)})'
1136
+ : '📎 $name (${_formatSize(size)})',
1137
+ status: MessageStatus.sent,
1138
+ ),
1139
+ );
10411140 }
10421141 }
10431142
....@@ -1049,14 +1148,25 @@
10491148 String _guessMimeType(String name) {
10501149 final ext = name.split('.').last.toLowerCase();
10511150 const map = {
1052
- 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
1053
- 'gif': 'image/gif', 'webp': 'image/webp', 'heic': 'image/heic',
1054
- 'pdf': 'application/pdf', 'doc': 'application/msword',
1055
- 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1151
+ 'jpg': 'image/jpeg',
1152
+ 'jpeg': 'image/jpeg',
1153
+ 'png': 'image/png',
1154
+ 'gif': 'image/gif',
1155
+ 'webp': 'image/webp',
1156
+ 'heic': 'image/heic',
1157
+ 'pdf': 'application/pdf',
1158
+ 'doc': 'application/msword',
1159
+ 'docx':
1160
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
10561161 'xls': 'application/vnd.ms-excel',
1057
- 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1058
- 'txt': 'text/plain', 'csv': 'text/csv', 'json': 'application/json',
1059
- 'zip': 'application/zip', 'mp3': 'audio/mpeg', 'mp4': 'video/mp4',
1162
+ 'xlsx':
1163
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1164
+ 'txt': 'text/plain',
1165
+ 'csv': 'text/csv',
1166
+ 'json': 'application/json',
1167
+ 'zip': 'application/zip',
1168
+ 'mp3': 'audio/mpeg',
1169
+ 'mp4': 'video/mp4',
10601170 };
10611171 return map[ext] ?? 'application/octet-stream';
10621172 }
....@@ -1069,7 +1179,9 @@
10691179
10701180 void _requestScreenshot() {
10711181 _screenshotForChat = true;
1072
- _sendCommand('screenshot', {'sessionId': ref.read(activeSessionIdProvider)});
1182
+ _sendCommand('screenshot', {
1183
+ 'sessionId': ref.read(activeSessionIdProvider),
1184
+ });
10731185 if (mounted) {
10741186 ScaffoldMessenger.of(context).showSnackBar(
10751187 const SnackBar(
....@@ -1169,15 +1281,17 @@
11691281 textCaption = '';
11701282 }
11711283
1172
- final attachments = encodedImages.map((b64) =>
1173
- <String, dynamic>{'data': b64, 'mimeType': 'image/jpeg'}
1174
- ).toList();
1284
+ final attachments = encodedImages
1285
+ .map((b64) => <String, dynamic>{'data': b64, 'mimeType': 'image/jpeg'})
1286
+ .toList();
11751287
11761288 // Create the first image message early so we have its ID for transcript reflection
11771289 final firstImageMsg = Message.image(
11781290 role: MessageRole.user,
11791291 imageBase64: encodedImages[0],
1180
- content: textCaption.isNotEmpty ? textCaption : (voiceB64 != null ? '🎤 ...' : ''),
1292
+ content: textCaption.isNotEmpty
1293
+ ? textCaption
1294
+ : (voiceB64 != null ? '🎤 ...' : ''),
11811295 status: MessageStatus.sent,
11821296 );
11831297
....@@ -1251,9 +1365,16 @@
12511365 child: const Row(
12521366 mainAxisAlignment: MainAxisAlignment.center,
12531367 children: [
1254
- Icon(Icons.fiber_manual_record, color: Colors.red, size: 16),
1368
+ Icon(
1369
+ Icons.fiber_manual_record,
1370
+ color: Colors.red,
1371
+ size: 16,
1372
+ ),
12551373 SizedBox(width: 8),
1256
- Text('Recording voice caption...', style: TextStyle(fontSize: 16)),
1374
+ Text(
1375
+ 'Recording voice caption...',
1376
+ style: TextStyle(fontSize: 16),
1377
+ ),
12571378 ],
12581379 ),
12591380 ),
....@@ -1266,7 +1387,10 @@
12661387 children: [
12671388 Icon(Icons.check_circle, color: Colors.green, size: 20),
12681389 SizedBox(width: 8),
1269
- Text('Voice caption recorded', style: TextStyle(fontSize: 16)),
1390
+ Text(
1391
+ 'Voice caption recorded',
1392
+ style: TextStyle(fontSize: 16),
1393
+ ),
12701394 ],
12711395 ),
12721396 ),
....@@ -1357,7 +1481,10 @@
13571481 Navigator.pop(ctx);
13581482 ref.read(messagesProvider.notifier).clearMessages();
13591483 },
1360
- child: const Text('Clear', style: TextStyle(color: AppColors.error)),
1484
+ child: const Text(
1485
+ 'Clear',
1486
+ style: TextStyle(color: AppColors.error),
1487
+ ),
13611488 ),
13621489 ],
13631490 ),
....@@ -1376,7 +1503,10 @@
13761503 final next = current == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
13771504 ref.read(themeModeProvider.notifier).state = next;
13781505 final prefs = await SharedPreferences.getInstance();
1379
- await prefs.setString('theme_mode', next == ThemeMode.dark ? 'dark' : 'light');
1506
+ await prefs.setString(
1507
+ 'theme_mode',
1508
+ next == ThemeMode.dark ? 'dark' : 'light',
1509
+ );
13801510 }
13811511
13821512 void _scrollToBottom() {
....@@ -1392,7 +1522,46 @@
13921522 }
13931523
13941524 void _handleNewSession() {
1395
- _sendCommand('create');
1525
+ // Close the drawer, fetch the latest project list, and present the launcher.
1526
+ Navigator.of(context).pop();
1527
+ _sendCommand('projects');
1528
+ showModalBottomSheet(
1529
+ context: context,
1530
+ isScrollControlled: true,
1531
+ backgroundColor: Theme.of(context).canvasColor,
1532
+ shape: const RoundedRectangleBorder(
1533
+ borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
1534
+ ),
1535
+ builder: (_) => NewSessionSheet(
1536
+ onLaunchProject: (project, rehome) {
1537
+ final activeId = ref.read(activeSessionIdProvider);
1538
+ if (rehome && activeId != null) {
1539
+ _sendCommand('rehome', {
1540
+ 'sessionId': activeId,
1541
+ 'path': project.path,
1542
+ 'name': project.name,
1543
+ });
1544
+ } else {
1545
+ _sendCommand('create', {
1546
+ 'project': project.launch,
1547
+ 'name': project.name,
1548
+ });
1549
+ }
1550
+ },
1551
+ onLaunchPath: (path, name, rehome) {
1552
+ final activeId = ref.read(activeSessionIdProvider);
1553
+ if (rehome && activeId != null) {
1554
+ _sendCommand('rehome', {
1555
+ 'sessionId': activeId,
1556
+ 'path': path,
1557
+ 'name': name,
1558
+ });
1559
+ } else {
1560
+ _sendCommand('create', {'path': path, 'name': name});
1561
+ }
1562
+ },
1563
+ ),
1564
+ );
13961565 }
13971566
13981567 /// Called when the user taps an upgrade CTA in the drawer or paywall banner.
....@@ -1411,8 +1580,9 @@
14111580 void _handleSessionRemove(Session session) {
14121581 _sendCommand('remove', {'sessionId': session.id});
14131582 final sessions = ref.read(sessionsProvider);
1414
- ref.read(sessionsProvider.notifier).state =
1415
- sessions.where((s) => s.id != session.id).toList();
1583
+ ref.read(sessionsProvider.notifier).state = sessions
1584
+ .where((s) => s.id != session.id)
1585
+ .toList();
14161586 }
14171587
14181588 void _handleSessionReorder(int oldIndex, int newIndex) {
....@@ -1428,13 +1598,16 @@
14281598 }
14291599
14301600 void _saveSessionOrder(List<String> ids) {
1431
- SharedPreferences.getInstance().then((p) => p.setStringList('sessionOrder', ids));
1601
+ SharedPreferences.getInstance().then(
1602
+ (p) => p.setStringList('sessionOrder', ids),
1603
+ );
14321604 }
14331605
14341606 /// Apply saved custom order to a server-provided session list.
14351607 /// New sessions (not in saved order) are appended at the end.
14361608 List<Session> _applyCustomOrder(List<Session> sessions) {
1437
- if (_cachedSessionOrder == null || _cachedSessionOrder!.isEmpty) return sessions;
1609
+ if (_cachedSessionOrder == null || _cachedSessionOrder!.isEmpty)
1610
+ return sessions;
14381611 final order = _cachedSessionOrder!;
14391612 final byId = {for (final s in sessions) s.id: s};
14401613 final ordered = <Session>[];
....@@ -1476,136 +1649,145 @@
14761649 behavior: HitTestBehavior.translucent,
14771650 onTap: () => FocusScope.of(context).unfocus(),
14781651 child: Scaffold(
1479
- key: _scaffoldKey,
1480
- appBar: AppBar(
1481
- leading: IconButton(
1482
- icon: const Icon(Icons.menu),
1483
- onPressed: () {
1484
- FocusScope.of(context).unfocus();
1485
- _scaffoldKey.currentState?.openDrawer();
1486
- },
1487
- ),
1488
- title: Column(
1489
- crossAxisAlignment: CrossAxisAlignment.center,
1490
- mainAxisSize: MainAxisSize.min,
1491
- children: [
1492
- Text(
1493
- activeSession?.name ?? 'PAILot',
1494
- style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
1652
+ key: _scaffoldKey,
1653
+ appBar: AppBar(
1654
+ leading: IconButton(
1655
+ icon: const Icon(Icons.menu),
1656
+ onPressed: () {
1657
+ FocusScope.of(context).unfocus();
1658
+ _scaffoldKey.currentState?.openDrawer();
1659
+ },
1660
+ ),
1661
+ title: Column(
1662
+ crossAxisAlignment: CrossAxisAlignment.center,
1663
+ mainAxisSize: MainAxisSize.min,
1664
+ children: [
1665
+ Text(
1666
+ activeSession?.name ?? 'PAILot',
1667
+ style: const TextStyle(
1668
+ fontSize: 16,
1669
+ fontWeight: FontWeight.w600,
1670
+ ),
1671
+ ),
1672
+ if (connectionDetail.isNotEmpty &&
1673
+ wsStatus != ConnectionStatus.connected)
1674
+ Text(
1675
+ connectionDetail,
1676
+ style: TextStyle(fontSize: 11, color: Colors.grey.shade400),
1677
+ ),
1678
+ if (wsStatus == ConnectionStatus.connected &&
1679
+ ref.watch(connectedViaProvider).isNotEmpty)
1680
+ Text(
1681
+ 'via ${ref.watch(connectedViaProvider)}',
1682
+ style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
1683
+ ),
1684
+ ],
1685
+ ),
1686
+ actions: [
1687
+ StatusDot(status: wsStatus),
1688
+ const SizedBox(width: 12),
1689
+ IconButton(
1690
+ icon: Icon(
1691
+ Theme.of(context).brightness == Brightness.dark
1692
+ ? Icons.light_mode
1693
+ : Icons.dark_mode,
1694
+ size: 20,
1695
+ ),
1696
+ onPressed: _toggleTheme,
14951697 ),
1496
- if (connectionDetail.isNotEmpty && wsStatus != ConnectionStatus.connected)
1497
- Text(
1498
- connectionDetail,
1499
- style: TextStyle(fontSize: 11, color: Colors.grey.shade400),
1500
- ),
1501
- if (wsStatus == ConnectionStatus.connected && ref.watch(connectedViaProvider).isNotEmpty)
1502
- Text(
1503
- 'via ${ref.watch(connectedViaProvider)}',
1504
- style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
1505
- ),
1698
+ IconButton(
1699
+ icon: const Icon(Icons.settings, size: 20),
1700
+ onPressed: () => context.push('/settings'),
1701
+ ),
15061702 ],
15071703 ),
1508
- actions: [
1509
- StatusDot(status: wsStatus),
1510
- const SizedBox(width: 12),
1511
- IconButton(
1512
- icon: Icon(
1513
- Theme.of(context).brightness == Brightness.dark
1514
- ? Icons.light_mode
1515
- : Icons.dark_mode,
1516
- size: 20,
1704
+ onDrawerChanged: (isOpened) {
1705
+ if (isOpened) FocusManager.instance.primaryFocus?.unfocus();
1706
+ },
1707
+ drawer: SessionDrawer(
1708
+ sessions: sessions,
1709
+ activeSessionId: activeSession?.id,
1710
+ unreadCounts: unreadCounts,
1711
+ isPro: ref.watch(isProProvider),
1712
+ onSelect: (s) => _switchSession(s.id),
1713
+ onRemove: _handleSessionRemove,
1714
+ onRename: _handleSessionRename,
1715
+ onReorder: _handleSessionReorder,
1716
+ onNewSession: _handleNewSession,
1717
+ onRefresh: _refreshSessions,
1718
+ onUpgrade: _handleUpgrade,
1719
+ ),
1720
+ body: Column(
1721
+ children: [
1722
+ const PaywallBanner(),
1723
+ Expanded(
1724
+ child: ListView.builder(
1725
+ controller: _scrollController,
1726
+ reverse: true,
1727
+ padding: const EdgeInsets.only(top: 8, bottom: 8),
1728
+ itemCount: messages.length + (isTyping ? 1 : 0),
1729
+ itemBuilder: (context, index) {
1730
+ if (isTyping && index == 0) {
1731
+ return const TypingIndicator();
1732
+ }
1733
+
1734
+ final msgIndex = isTyping
1735
+ ? messages.length - index
1736
+ : messages.length - 1 - index;
1737
+
1738
+ if (msgIndex < 0 || msgIndex >= messages.length) {
1739
+ return const SizedBox.shrink();
1740
+ }
1741
+
1742
+ final message = messages[msgIndex];
1743
+ return MessageBubble(
1744
+ message: message,
1745
+ isPlaying: _playingMessageId == message.id,
1746
+ onPlay: message.type == MessageType.voice
1747
+ ? () => _playMessage(message)
1748
+ : null,
1749
+ onChainPlay:
1750
+ message.type == MessageType.voice &&
1751
+ message.role == MessageRole.assistant
1752
+ ? () => _chainPlayFrom(message)
1753
+ : null,
1754
+ onDelete: () {
1755
+ ref
1756
+ .read(messagesProvider.notifier)
1757
+ .removeMessage(message.id);
1758
+ },
1759
+ );
1760
+ },
1761
+ ),
15171762 ),
1518
- onPressed: _toggleTheme,
1519
- ),
1520
- IconButton(
1521
- icon: const Icon(Icons.settings, size: 20),
1522
- onPressed: () => context.push('/settings'),
1523
- ),
1524
- ],
1525
- ),
1526
- onDrawerChanged: (isOpened) {
1527
- if (isOpened) FocusManager.instance.primaryFocus?.unfocus();
1528
- },
1529
- drawer: SessionDrawer(
1530
- sessions: sessions,
1531
- activeSessionId: activeSession?.id,
1532
- unreadCounts: unreadCounts,
1533
- isPro: ref.watch(isProProvider),
1534
- onSelect: (s) => _switchSession(s.id),
1535
- onRemove: _handleSessionRemove,
1536
- onRename: _handleSessionRename,
1537
- onReorder: _handleSessionReorder,
1538
- onNewSession: _handleNewSession,
1539
- onRefresh: _refreshSessions,
1540
- onUpgrade: _handleUpgrade,
1541
- ),
1542
- body: Column(
1543
- children: [
1544
- const PaywallBanner(),
1545
- Expanded(
1546
- child: ListView.builder(
1547
- controller: _scrollController,
1548
- reverse: true,
1549
- padding: const EdgeInsets.only(top: 8, bottom: 8),
1550
- itemCount: messages.length + (isTyping ? 1 : 0),
1551
- itemBuilder: (context, index) {
1552
- if (isTyping && index == 0) {
1553
- return const TypingIndicator();
1554
- }
1555
-
1556
- final msgIndex = isTyping
1557
- ? messages.length - index
1558
- : messages.length - 1 - index;
1559
-
1560
- if (msgIndex < 0 || msgIndex >= messages.length) {
1561
- return const SizedBox.shrink();
1562
- }
1563
-
1564
- final message = messages[msgIndex];
1565
- return MessageBubble(
1566
- message: message,
1567
- isPlaying: _playingMessageId == message.id,
1568
- onPlay: message.type == MessageType.voice
1569
- ? () => _playMessage(message)
1570
- : null,
1571
- onChainPlay: message.type == MessageType.voice &&
1572
- message.role == MessageRole.assistant
1573
- ? () => _chainPlayFrom(message)
1574
- : null,
1575
- onDelete: () {
1576
- ref.read(messagesProvider.notifier).removeMessage(message.id);
1577
- },
1578
- );
1763
+ CommandBar(
1764
+ onScreen: _requestScreenshot,
1765
+ onNavigate: _navigateToTerminal,
1766
+ onPhoto: _pickPhoto,
1767
+ onClear: _clearChat,
1768
+ onHelp: inputMode == InputMode.text ? _sendHelp : null,
1769
+ showHelp: inputMode == InputMode.text,
1770
+ ),
1771
+ InputBar(
1772
+ mode: inputMode,
1773
+ isRecording: _isRecording,
1774
+ textController: _textController,
1775
+ onToggleMode: () {
1776
+ ref
1777
+ .read(inputModeProvider.notifier)
1778
+ .state = inputMode == InputMode.voice
1779
+ ? InputMode.text
1780
+ : InputMode.voice;
15791781 },
1782
+ onRecordStart: _startRecording,
1783
+ onRecordStop: _stopRecording,
1784
+ onRecordCancel: _cancelRecording,
1785
+ onReplay: _replayLast,
1786
+ onSendText: _sendTextMessage,
15801787 ),
1581
- ),
1582
- CommandBar(
1583
- onScreen: _requestScreenshot,
1584
- onNavigate: _navigateToTerminal,
1585
- onPhoto: _pickPhoto,
1586
- onClear: _clearChat,
1587
- onHelp: inputMode == InputMode.text ? _sendHelp : null,
1588
- showHelp: inputMode == InputMode.text,
1589
- ),
1590
- InputBar(
1591
- mode: inputMode,
1592
- isRecording: _isRecording,
1593
- textController: _textController,
1594
- onToggleMode: () {
1595
- ref.read(inputModeProvider.notifier).state =
1596
- inputMode == InputMode.voice
1597
- ? InputMode.text
1598
- : InputMode.voice;
1599
- },
1600
- onRecordStart: _startRecording,
1601
- onRecordStop: _stopRecording,
1602
- onRecordCancel: _cancelRecording,
1603
- onReplay: _replayLast,
1604
- onSendText: _sendTextMessage,
1605
- ),
1606
- ],
1788
+ ],
1789
+ ),
16071790 ),
1608
- ),
16091791 );
16101792 }
16111793 }
lib/services/mqtt_service.dart
....@@ -20,12 +20,7 @@
2020 import 'wol_service.dart';
2121
2222 /// Connection status for the MQTT client.
23
-enum ConnectionStatus {
24
- disconnected,
25
- connecting,
26
- connected,
27
- reconnecting,
28
-}
23
+enum ConnectionStatus { disconnected, connecting, connected, reconnecting }
2924
3025 // Debug log — writes to file only in debug builds, always prints via debugPrint.
3126 // Also adds entries to TraceService so they appear in the trace log viewer.
....@@ -77,10 +72,12 @@
7772
7873 // Callbacks
7974 void Function(ConnectionStatus status)? onStatusChanged;
80
- void Function(String detail)? onStatusDetail; // "Probing local...", "Scanning network..."
75
+ void Function(String detail)?
76
+ onStatusDetail; // "Probing local...", "Scanning network..."
8177 String? connectedHost; // The host we're currently connected to
8278 String? connectedVia; // "Local", "VPN", "Remote", "Bonjour", "Scan"
8379 void Function(Map<String, dynamic> message)? onMessage;
80
+
8481 /// Called when the server sends a debug_state_request on pailot/control/out.
8582 /// The handler should read current session state and call [publishDebugStateResponse].
8683 void Function(String requestId)? onDebugStateRequest;
....@@ -112,6 +109,29 @@
112109 }
113110 _clientId = id;
114111 return id;
112
+ }
113
+
114
+ // The host that last connected successfully, persisted across app restarts so
115
+ // a cold start (iOS killed the backgrounded app) can reconnect fast instead of
116
+ // re-running the LAN race + network scan when only the VPN host is reachable.
117
+ static const String _kLastHostKey = 'mqtt_last_good_host';
118
+
119
+ Future<void> _saveLastGoodHost(String host) async {
120
+ try {
121
+ final prefs = await SharedPreferences.getInstance();
122
+ await prefs.setString(_kLastHostKey, host);
123
+ } catch (_) {}
124
+ }
125
+
126
+ Future<String?> _loadLastGoodHost() async {
127
+ try {
128
+ final h = (await SharedPreferences.getInstance()).getString(
129
+ _kLastHostKey,
130
+ );
131
+ return (h != null && h.isNotEmpty) ? h : null;
132
+ } catch (_) {
133
+ return null;
134
+ }
115135 }
116136
117137 /// Force reconnect — disconnect and reconnect to last known host.
....@@ -168,13 +188,41 @@
168188
169189 final clientId = await _getClientId();
170190
191
+ // Phase 0: Fast path — try the last host that worked (persisted across app
192
+ // restarts) before racing all hosts or scanning. On cellular/Tailscale the
193
+ // LAN host and mDNS are unreachable, so this avoids the slow scan every cold
194
+ // start. If it's stale/unreachable it times out quickly and we fall through.
195
+ final lastGood = await _loadLastGoodHost();
196
+ if (lastGood != null && !_intentionalClose) {
197
+ onStatusDetail?.call('Reconnecting…');
198
+ _mqttLog('MQTT: fast path — trying last-good host $lastGood');
199
+ if (await _tryConnect(lastGood, clientId, timeout: 2500)) {
200
+ if (lastGood == config.localHost) {
201
+ connectedVia = 'Local';
202
+ } else if (lastGood == config.vpnHost) {
203
+ connectedVia = 'VPN';
204
+ } else if (lastGood == config.host) {
205
+ connectedVia = 'Remote';
206
+ } else {
207
+ connectedVia = 'Reconnected';
208
+ }
209
+ _mqttLog('MQTT: fast path connected via $connectedVia');
210
+ return;
211
+ }
212
+ }
213
+
171214 // Phase 1: Race configured hosts (fast — just TLS probe, ~1s each)
172215 final hosts = <String>[];
173
- if (config.localHost != null && config.localHost!.isNotEmpty) hosts.add(config.localHost!);
174
- if (_lastDiscoveredHost != null && !hosts.contains(_lastDiscoveredHost)) hosts.add(_lastDiscoveredHost!);
175
- if (config.vpnHost != null && config.vpnHost!.isNotEmpty) hosts.add(config.vpnHost!);
216
+ if (config.localHost != null && config.localHost!.isNotEmpty)
217
+ hosts.add(config.localHost!);
218
+ if (_lastDiscoveredHost != null && !hosts.contains(_lastDiscoveredHost))
219
+ hosts.add(_lastDiscoveredHost!);
220
+ if (config.vpnHost != null && config.vpnHost!.isNotEmpty)
221
+ hosts.add(config.vpnHost!);
176222 if (config.host.isNotEmpty) hosts.add(config.host);
177
- _mqttLog('MQTT: racing ${hosts.length} configured hosts: ${hosts.join(", ")}');
223
+ _mqttLog(
224
+ 'MQTT: racing ${hosts.length} configured hosts: ${hosts.join(", ")}',
225
+ );
178226 onStatusDetail?.call('Connecting...');
179227
180228 // Race: first probe to succeed wins, don't wait for others
....@@ -250,7 +298,9 @@
250298 /// Discover AIBroker on local network via Bonjour/mDNS.
251299 /// Falls back to subnet scan if Bonjour fails (iOS blocks mDNS on Personal Hotspot).
252300 /// Returns the IP address or null if not found within timeout.
253
- Future<String?> _discoverViaMdns({Duration timeout = const Duration(seconds: 3)}) async {
301
+ Future<String?> _discoverViaMdns({
302
+ Duration timeout = const Duration(seconds: 3),
303
+ }) async {
254304 // Try Bonjour first
255305 try {
256306 final discovery = BonsoirDiscovery(type: '_mqtt._tcp');
....@@ -263,7 +313,9 @@
263313 switch (event) {
264314 case BonsoirDiscoveryServiceResolvedEvent():
265315 final ip = event.service.host;
266
- _mqttLog('MQTT: Bonjour resolved: ${event.service.name} at $ip:${event.service.port}');
316
+ _mqttLog(
317
+ 'MQTT: Bonjour resolved: ${event.service.name} at $ip:${event.service.port}',
318
+ );
267319 if (ip != null && ip.isNotEmpty && !completer.isCompleted) {
268320 completer.complete(ip);
269321 }
....@@ -297,7 +349,9 @@
297349 Future<String?> _scanSubnetForMqtt() async {
298350 try {
299351 // Get device's own IP to determine the subnet
300
- final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
352
+ final interfaces = await NetworkInterface.list(
353
+ type: InternetAddressType.IPv4,
354
+ );
301355 for (final iface in interfaces) {
302356 for (final addr in iface.addresses) {
303357 final parts = addr.address.split('.');
....@@ -319,7 +373,10 @@
319373 futures.add(_probeHost(probe, config.port));
320374 }
321375 final results = await Future.wait(futures);
322
- final found = results.firstWhere((r) => r != null, orElse: () => null);
376
+ final found = results.firstWhere(
377
+ (r) => r != null,
378
+ orElse: () => null,
379
+ );
323380 if (found != null) {
324381 _mqttLog('MQTT: subnet scan found broker at $found');
325382 return found;
....@@ -342,7 +399,9 @@
342399 final prefs = await SharedPreferences.getInstance();
343400 _trustedFingerprint = prefs.getString('trustedCertFingerprint');
344401 if (_trustedFingerprint != null) {
345
- _mqttLog('TOFU: loaded trusted fingerprint: ${_trustedFingerprint!.substring(0, 16)}...');
402
+ _mqttLog(
403
+ 'TOFU: loaded trusted fingerprint: ${_trustedFingerprint!.substring(0, 16)}...',
404
+ );
346405 }
347406 }
348407
....@@ -365,7 +424,9 @@
365424 SharedPreferences.getInstance().then((prefs) {
366425 prefs.setString('trustedCertFingerprint', fingerprint);
367426 });
368
- _mqttLog('TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...');
427
+ _mqttLog(
428
+ 'TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...',
429
+ );
369430 return true;
370431 }
371432
....@@ -374,7 +435,9 @@
374435 }
375436
376437 // Fingerprint mismatch — possible MITM or server reinstall
377
- _mqttLog('TOFU: CERT MISMATCH! Expected ${_trustedFingerprint!.substring(0, 16)}... got ${fingerprint.substring(0, 16)}...');
438
+ _mqttLog(
439
+ 'TOFU: CERT MISMATCH! Expected ${_trustedFingerprint!.substring(0, 16)}... got ${fingerprint.substring(0, 16)}...',
440
+ );
378441 // Reject the connection. User must reset trust in settings.
379442 return false;
380443 }
....@@ -404,11 +467,17 @@
404467 }
405468 }
406469
407
- Future<bool> _tryConnect(String host, String clientId, {int timeout = 5000}) async {
470
+ Future<bool> _tryConnect(
471
+ String host,
472
+ String clientId, {
473
+ int timeout = 5000,
474
+ }) async {
408475 try {
409476 final client = MqttServerClient.withPort(host, clientId, config.port);
410
- client.keepAlivePeriod = 120; // 2 min — iOS throttles bg network, short keepalive causes drops
411
- client.autoReconnect = false; // Don't auto-reconnect during trial — enable after success
477
+ client.keepAlivePeriod =
478
+ 120; // 2 min — iOS throttles bg network, short keepalive causes drops
479
+ client.autoReconnect =
480
+ false; // Don't auto-reconnect during trial — enable after success
412481 client.connectTimeoutPeriod = timeout;
413482 // client.maxConnectionAttempts is final — can't set it
414483 client.logging(on: false);
....@@ -440,7 +509,9 @@
440509 // Set _client BEFORE connect() so _onConnected can subscribe
441510 _client = client;
442511
443
- _mqttLog('MQTT: connecting to $host:${config.port} as $clientId (timeout=${timeout}ms)');
512
+ _mqttLog(
513
+ 'MQTT: connecting to $host:${config.port} as $clientId (timeout=${timeout}ms)',
514
+ );
444515 final result = await client.connect().timeout(
445516 Duration(milliseconds: timeout + 1000),
446517 onTimeout: () {
....@@ -453,6 +524,8 @@
453524 // Don't use autoReconnect — it has no backoff and causes tight reconnect loops.
454525 // We handle reconnection manually in _onDisconnected with exponential backoff.
455526 _reconnectAttempt = 0;
527
+ connectedHost = host;
528
+ _saveLastGoodHost(host); // remember for a fast reconnect after restart
456529 return true;
457530 }
458531 _client = null;
....@@ -471,12 +544,17 @@
471544 // STABLE for 10+ seconds. This prevents flap loops where each brief connect
472545 // resets the backoff and we hammer the server every 5s forever.
473546 _stabilityTimer?.cancel();
474
- _stabilityTimer = Timer(const Duration(milliseconds: _stabilityThresholdMs), () {
475
- if (_status == ConnectionStatus.connected) {
476
- _mqttLog('MQTT: connection stable for ${_stabilityThresholdMs}ms — resetting backoff');
477
- _reconnectAttempt = 0;
478
- }
479
- });
547
+ _stabilityTimer = Timer(
548
+ const Duration(milliseconds: _stabilityThresholdMs),
549
+ () {
550
+ if (_status == ConnectionStatus.connected) {
551
+ _mqttLog(
552
+ 'MQTT: connection stable for ${_stabilityThresholdMs}ms — resetting backoff',
553
+ );
554
+ _reconnectAttempt = 0;
555
+ }
556
+ },
557
+ );
480558 _setStatus(ConnectionStatus.connected);
481559 _subscribe();
482560 _listenMessages();
....@@ -501,9 +579,14 @@
501579 void _scheduleReconnect() {
502580 _reconnectTimer?.cancel();
503581 // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s cap
504
- final delayMs = (1000 * (1 << _reconnectAttempt)).clamp(1000, _maxReconnectDelay);
582
+ final delayMs = (1000 * (1 << _reconnectAttempt)).clamp(
583
+ 1000,
584
+ _maxReconnectDelay,
585
+ );
505586 _reconnectAttempt++;
506
- _mqttLog('MQTT: scheduling reconnect in ${delayMs}ms (attempt $_reconnectAttempt)');
587
+ _mqttLog(
588
+ 'MQTT: scheduling reconnect in ${delayMs}ms (attempt $_reconnectAttempt)',
589
+ );
507590 _reconnectTimer = Timer(Duration(milliseconds: delayMs), () async {
508591 if (_intentionalClose || _status == ConnectionStatus.connected) return;
509592 final host = connectedHost ?? _lastDiscoveredHost;
....@@ -675,7 +758,9 @@
675758 /// Publish raw bytes to a topic. Used by TraceService for log streaming.
676759 void publishRaw(String topic, Uint8Buffer payload, MqttQos qos) {
677760 final client = _client;
678
- if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) return;
761
+ if (client == null ||
762
+ client.connectionStatus?.state != MqttConnectionState.connected)
763
+ return;
679764 try {
680765 client.publishMessage(topic, qos, payload);
681766 } catch (_) {}
....@@ -684,7 +769,8 @@
684769 /// Publish a JSON payload to an MQTT topic.
685770 void _publish(String topic, Map<String, dynamic> payload, MqttQos qos) {
686771 final client = _client;
687
- if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) {
772
+ if (client == null ||
773
+ client.connectionStatus?.state != MqttConnectionState.connected) {
688774 onError?.call('Not connected');
689775 return;
690776 }
....@@ -718,7 +804,9 @@
718804 if (platform != null) 'platform': platform,
719805 'ts': DateTime.now().millisecondsSinceEpoch,
720806 }, MqttQos.atLeastOnce);
721
- _mqttLog('debug_state_response sent for requestId=$requestId sessions=${sessions.length}');
807
+ _mqttLog(
808
+ 'debug_state_response sent for requestId=$requestId sessions=${sessions.length}',
809
+ );
722810 }
723811
724812 /// Send a message — routes to the appropriate MQTT topic based on content.
....@@ -774,8 +862,10 @@
774862 'type': 'bundle',
775863 'sessionId': sessionId,
776864 'caption': message['caption'] ?? '',
777
- if (message['audioBase64'] != null) 'audioBase64': message['audioBase64'],
778
- if (message['voiceMessageId'] != null) 'voiceMessageId': message['voiceMessageId'],
865
+ if (message['audioBase64'] != null)
866
+ 'audioBase64': message['audioBase64'],
867
+ if (message['voiceMessageId'] != null)
868
+ 'voiceMessageId': message['voiceMessageId'],
779869 'attachments': message['attachments'] ?? [],
780870 'ts': _now(),
781871 }, MqttQos.atLeastOnce);
....@@ -830,13 +920,20 @@
830920 /// no MQTT clients are connected (app is backgrounded or offline).
831921 void sendDeviceToken(String token) {
832922 final client = _client;
833
- if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) {
923
+ if (client == null ||
924
+ client.connectionStatus?.state != MqttConnectionState.connected) {
834925 return;
835926 }
836927 try {
837928 final builder = MqttClientPayloadBuilder();
838
- builder.addString('{"token":"$token","ts":${DateTime.now().millisecondsSinceEpoch}}');
839
- client.publishMessage('pailot/device/token', MqttQos.atLeastOnce, builder.payload!);
929
+ builder.addString(
930
+ '{"token":"$token","ts":${DateTime.now().millisecondsSinceEpoch}}',
931
+ );
932
+ client.publishMessage(
933
+ 'pailot/device/token',
934
+ MqttQos.atLeastOnce,
935
+ builder.payload!,
936
+ );
840937 _mqttLog('Push: device token published to pailot/device/token');
841938 } catch (e) {
842939 _mqttLog('Push: failed to publish device token: $e');
lib/widgets/new_session_sheet.dart
....@@ -0,0 +1,223 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+
4
+import '../models/project.dart';
5
+import '../providers/providers.dart';
6
+
7
+/// Bottom sheet for starting a new session: search your PAI projects (like PAI
8
+/// search), or open the home directory / any arbitrary directory.
9
+///
10
+/// The list comes from [projectsProvider], which the chat screen populates by
11
+/// sending the `projects` command when this sheet opens.
12
+class NewSessionSheet extends ConsumerStatefulWidget {
13
+ /// Open a project. rehome=false → new session; rehome=true → re-home the
14
+ /// current tab to this project's directory.
15
+ final void Function(Project project, bool rehome) onLaunchProject;
16
+
17
+ /// Open an arbitrary directory ("~" for home). rehome as above.
18
+ final void Function(String path, String name, bool rehome) onLaunchPath;
19
+
20
+ const NewSessionSheet({
21
+ super.key,
22
+ required this.onLaunchProject,
23
+ required this.onLaunchPath,
24
+ });
25
+
26
+ @override
27
+ ConsumerState<NewSessionSheet> createState() => _NewSessionSheetState();
28
+}
29
+
30
+class _NewSessionSheetState extends ConsumerState<NewSessionSheet> {
31
+ final _searchController = TextEditingController();
32
+ String _query = '';
33
+ bool _rehome = false; // false = new session, true = re-home the current tab
34
+
35
+ @override
36
+ void dispose() {
37
+ _searchController.dispose();
38
+ super.dispose();
39
+ }
40
+
41
+ List<Project> _filtered(List<Project> projects) {
42
+ final list = [...projects]
43
+ ..sort(
44
+ (a, b) => b.lastActive.compareTo(a.lastActive),
45
+ ); // most recent first
46
+ final q = _query.trim().toLowerCase();
47
+ if (q.isEmpty) return list;
48
+ return list
49
+ .where(
50
+ (p) =>
51
+ p.name.toLowerCase().contains(q) ||
52
+ p.slug.toLowerCase().contains(q) ||
53
+ p.path.toLowerCase().contains(q),
54
+ )
55
+ .toList();
56
+ }
57
+
58
+ void _launchProject(Project p) {
59
+ Navigator.of(context).pop();
60
+ widget.onLaunchProject(p, _rehome);
61
+ }
62
+
63
+ void _launchPath(String path, String name) {
64
+ Navigator.of(context).pop();
65
+ widget.onLaunchPath(path, name, _rehome);
66
+ }
67
+
68
+ Future<void> _promptCustomDir() async {
69
+ final controller = TextEditingController();
70
+ final dir = await showDialog<String>(
71
+ context: context,
72
+ builder: (ctx) => AlertDialog(
73
+ title: const Text('Open a directory'),
74
+ content: TextField(
75
+ controller: controller,
76
+ autofocus: true,
77
+ decoration: const InputDecoration(
78
+ hintText: '/Users/you/dev/apps/youdrill',
79
+ ),
80
+ onSubmitted: (v) => Navigator.pop(ctx, v),
81
+ ),
82
+ actions: [
83
+ TextButton(
84
+ onPressed: () => Navigator.pop(ctx),
85
+ child: const Text('Cancel'),
86
+ ),
87
+ TextButton(
88
+ onPressed: () => Navigator.pop(ctx, controller.text),
89
+ child: const Text('Open'),
90
+ ),
91
+ ],
92
+ ),
93
+ );
94
+ controller.dispose();
95
+ final path = dir?.trim() ?? '';
96
+ if (path.isEmpty) return;
97
+ final parts = path.split('/').where((s) => s.isNotEmpty).toList();
98
+ _launchPath(path, parts.isNotEmpty ? parts.last : 'Session');
99
+ }
100
+
101
+ @override
102
+ Widget build(BuildContext context) {
103
+ final projects = ref.watch(projectsProvider);
104
+ final filtered = _filtered(projects);
105
+
106
+ return Padding(
107
+ padding: EdgeInsets.only(
108
+ bottom: MediaQuery.of(context).viewInsets.bottom,
109
+ ),
110
+ child: DraggableScrollableSheet(
111
+ expand: false,
112
+ initialChildSize: 0.7,
113
+ minChildSize: 0.4,
114
+ maxChildSize: 0.92,
115
+ builder: (context, scrollController) {
116
+ return Column(
117
+ children: [
118
+ // Grabber
119
+ Container(
120
+ width: 40,
121
+ height: 4,
122
+ margin: const EdgeInsets.symmetric(vertical: 10),
123
+ decoration: BoxDecoration(
124
+ color: Colors.grey.withAlpha(120),
125
+ borderRadius: BorderRadius.circular(2),
126
+ ),
127
+ ),
128
+ Padding(
129
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
130
+ child: SegmentedButton<bool>(
131
+ segments: const [
132
+ ButtonSegment(
133
+ value: false,
134
+ label: Text('New session'),
135
+ icon: Icon(Icons.add, size: 18),
136
+ ),
137
+ ButtonSegment(
138
+ value: true,
139
+ label: Text('Switch this tab'),
140
+ icon: Icon(Icons.swap_horiz, size: 18),
141
+ ),
142
+ ],
143
+ selected: {_rehome},
144
+ showSelectedIcon: false,
145
+ onSelectionChanged: (s) => setState(() => _rehome = s.first),
146
+ ),
147
+ ),
148
+ Padding(
149
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
150
+ child: TextField(
151
+ controller: _searchController,
152
+ autofocus: true,
153
+ textInputAction: TextInputAction.search,
154
+ decoration: InputDecoration(
155
+ hintText: 'Search projects…',
156
+ prefixIcon: const Icon(Icons.search),
157
+ border: OutlineInputBorder(
158
+ borderRadius: BorderRadius.circular(12),
159
+ ),
160
+ isDense: true,
161
+ ),
162
+ onChanged: (v) => setState(() => _query = v),
163
+ ),
164
+ ),
165
+ Expanded(
166
+ child: ListView(
167
+ controller: scrollController,
168
+ children: [
169
+ ListTile(
170
+ leading: const Icon(Icons.home_outlined),
171
+ title: const Text('Home directory'),
172
+ subtitle: const Text('New session in ~'),
173
+ onTap: () => _launchPath('~', 'Home'),
174
+ ),
175
+ ListTile(
176
+ leading: const Icon(Icons.folder_open_outlined),
177
+ title: const Text('Open a directory…'),
178
+ subtitle: const Text('Start in any path'),
179
+ onTap: _promptCustomDir,
180
+ ),
181
+ const Divider(height: 1),
182
+ if (projects.isEmpty)
183
+ const Padding(
184
+ padding: EdgeInsets.all(24),
185
+ child: Center(child: Text('Loading projects…')),
186
+ )
187
+ else if (filtered.isEmpty)
188
+ const Padding(
189
+ padding: EdgeInsets.all(24),
190
+ child: Center(child: Text('No matching projects')),
191
+ )
192
+ else
193
+ ...filtered.map(
194
+ (p) => ListTile(
195
+ leading: const Icon(Icons.folder_special_outlined),
196
+ title: Text(p.name),
197
+ subtitle: Text(
198
+ p.path,
199
+ maxLines: 1,
200
+ overflow: TextOverflow.ellipsis,
201
+ ),
202
+ trailing: p.sessions > 0
203
+ ? Text(
204
+ '${p.sessions}',
205
+ style: TextStyle(
206
+ color: Colors.grey.withAlpha(180),
207
+ fontSize: 13,
208
+ ),
209
+ )
210
+ : null,
211
+ onTap: () => _launchProject(p),
212
+ ),
213
+ ),
214
+ ],
215
+ ),
216
+ ),
217
+ ],
218
+ );
219
+ },
220
+ ),
221
+ );
222
+ }
223
+}