Matthias Nott
2026-07-10 1d79dcb975ae6f606a783a3d0287d8dc473e1cc8
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 }