From 1d79dcb975ae6f606a783a3d0287d8dc473e1cc8 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Fri, 10 Jul 2026 07:13:05 +0200
Subject: [PATCH] feat: session launcher (all-project search + switch-topic) and persistent last-host fast reconnect

---
 lib/screens/chat_screen.dart |  588 ++++++++++++++++++++++++++++++++++++++--------------------
 1 files changed, 385 insertions(+), 203 deletions(-)

diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart
index 8738410..745dbde 100644
--- a/lib/screens/chat_screen.dart
+++ b/lib/screens/chat_screen.dart
@@ -13,6 +13,7 @@
 import 'package:shared_preferences/shared_preferences.dart';
 
 import '../models/message.dart';
+import '../models/project.dart';
 import '../models/session.dart';
 // ignore: unused_import
 import '../models/server_config.dart';
@@ -30,6 +31,7 @@
 import '../widgets/message_bubble.dart';
 import '../widgets/paywall_banner.dart';
 import '../widgets/session_drawer.dart';
+import '../widgets/new_session_sheet.dart';
 import '../widgets/status_dot.dart';
 import '../widgets/toast_overlay.dart';
 import '../widgets/typing_indicator.dart';
@@ -100,8 +102,9 @@
     final savedUnreads = prefs.getString('unreadCounts');
     if (savedUnreads != null && mounted) {
       try {
-        final map = (jsonDecode(savedUnreads) as Map<String, dynamic>)
-            .map((k, v) => MapEntry(k, v as int));
+        final map = (jsonDecode(savedUnreads) as Map<String, dynamic>).map(
+          (k, v) => MapEntry(k, v as int),
+        );
         ref.read(unreadCountsProvider.notifier).state = map;
       } catch (_) {}
     }
@@ -221,7 +224,8 @@
         ref.read(wsStatusProvider.notifier).state = status;
         if (status == ConnectionStatus.connected) {
           ref.read(connectionDetailProvider.notifier).state = '';
-          ref.read(connectedViaProvider.notifier).state = _ws?.connectedVia ?? '';
+          ref.read(connectedViaProvider.notifier).state =
+              _ws?.connectedVia ?? '';
         } else {
           ref.read(connectedViaProvider.notifier).state = '';
         }
@@ -243,7 +247,10 @@
       Future.delayed(const Duration(milliseconds: 200), () {
         if (!mounted) return;
         final activeId = ref.read(activeSessionIdProvider);
-        _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null);
+        _sendCommand(
+          'sync',
+          activeId != null ? {'activeSessionId': activeId} : null,
+        );
         _push?.onMqttConnected();
       });
     };
@@ -270,7 +277,9 @@
         _sendCommand('nav', {'key': key});
       },
       requestScreenshot: (sessionId) {
-        _sendCommand('screenshot', {'sessionId': sessionId ?? ref.read(activeSessionIdProvider)});
+        _sendCommand('screenshot', {
+          'sessionId': sessionId ?? ref.read(activeSessionIdProvider),
+        });
       },
     );
 
@@ -332,6 +341,8 @@
     switch (type) {
       case 'sessions':
         _handleSessions(msg);
+      case 'projects':
+        _handleProjects(msg);
       case 'message':
       case 'text':
         _handleIncomingMessage(msg);
@@ -340,10 +351,16 @@
       case 'image':
         _handleIncomingImage(msg);
       case 'typing':
-        final typing = msg['typing'] as bool? ?? msg['isTyping'] as bool? ?? msg['active'] as bool? ?? true;
+        final typing =
+            msg['typing'] as bool? ??
+            msg['isTyping'] as bool? ??
+            msg['active'] as bool? ??
+            true;
         final typingSession = msg['sessionId'] as String?;
         final activeId = ref.read(activeSessionIdProvider);
-        _chatLog('TYPING: session=${typingSession?.substring(0, 8)} active=${activeId?.substring(0, 8)} typing=$typing match=${typingSession == activeId}');
+        _chatLog(
+          'TYPING: session=${typingSession?.substring(0, 8)} active=${activeId?.substring(0, 8)} typing=$typing match=${typingSession == activeId}',
+        );
         // Strict: only show typing for the ACTIVE session, ignore all others
         if (activeId != null && typingSession == activeId) {
           ref.read(isTypingProvider.notifier).state = typing;
@@ -369,6 +386,16 @@
       case 'clear':
         ref.read(messagesProvider.notifier).clearMessages();
       case 'session_switched':
+        // The hub switched/created a session (e.g. from the launcher) — follow it
+        // so tapping a project actually opens that session in the app.
+        final switchedId = msg['sessionId'] as String?;
+        if (switchedId != null && switchedId.isNotEmpty) {
+          ref.read(activeSessionIdProvider.notifier).state = switchedId;
+          ref.read(messagesProvider.notifier).switchSession(switchedId);
+          SharedPreferences.getInstance().then(
+            (p) => p.setString('activeSessionId', switchedId),
+          );
+        }
         _sendCommand('sessions');
       case 'session_renamed':
         _sendCommand('sessions');
@@ -380,7 +407,9 @@
           final currentMessages = ref.read(messagesProvider);
           final inCurrent = currentMessages.any((m) => m.id == messageId);
           if (inCurrent) {
-            ref.read(messagesProvider.notifier).updateContent(messageId, content);
+            ref
+                .read(messagesProvider.notifier)
+                .updateContent(messageId, content);
           } else {
             // Message is in a different session (user switched after recording).
             // Load that session's messages from disk, update, and save back.
@@ -404,7 +433,9 @@
         if (catchUpMsgs != null && catchUpMsgs.isNotEmpty) {
           _isCatchingUp = true;
           final activeId = ref.read(activeSessionIdProvider);
-          final currentId = ref.read(messagesProvider.notifier).currentSessionId;
+          final currentId = ref
+              .read(messagesProvider.notifier)
+              .currentSessionId;
           final existing = ref.read(messagesProvider);
           final existingContents = existing
               .where((m) => m.role == MessageRole.assistant)
@@ -418,14 +449,21 @@
           for (final m in catchUpMsgs) {
             final map = m as Map<String, dynamic>;
             final msgType = map['type'] as String? ?? 'text';
-            final content = map['content'] as String? ?? map['transcript'] as String? ?? map['caption'] as String? ?? '';
+            final content =
+                map['content'] as String? ??
+                map['transcript'] as String? ??
+                map['caption'] as String? ??
+                '';
             final msgSessionId = map['sessionId'] as String?;
             final imageData = map['imageBase64'] as String?;
 
             // Skip empty text messages (images with no caption are OK)
             if (content.isEmpty && imageData == null) continue;
             // Dedup by content (skip images from dedup — they have unique msgIds)
-            if (imageData == null && content.isNotEmpty && existingContents.contains(content)) continue;
+            if (imageData == null &&
+                content.isNotEmpty &&
+                existingContents.contains(content))
+              continue;
 
             final Message message;
             if (msgType == 'image' && imageData != null) {
@@ -444,7 +482,9 @@
               );
             }
 
-            _chatLog('catch_up msg: session=${msgSessionId?.substring(0, 8) ?? "NULL"} active=${activeId?.substring(0, 8)} content="${content.substring(0, content.length.clamp(0, 40))}"');
+            _chatLog(
+              'catch_up msg: session=${msgSessionId?.substring(0, 8) ?? "NULL"} active=${activeId?.substring(0, 8)} content="${content.substring(0, content.length.clamp(0, 40))}"',
+            );
 
             if (msgSessionId == null || msgSessionId == currentId) {
               // Active session or no session: add to UI (addMessage also appends to log).
@@ -453,7 +493,8 @@
               // Cross-session: synchronous append — no race condition.
               MessageStoreV2.append(msgSessionId, message);
               _incrementUnread(msgSessionId);
-              crossSessionCounts[msgSessionId] = (crossSessionCounts[msgSessionId] ?? 0) + 1;
+              crossSessionCounts[msgSessionId] =
+                  (crossSessionCounts[msgSessionId] ?? 0) + 1;
               crossSessionPreviews.putIfAbsent(msgSessionId, () => content);
             }
             existingContents.add(content);
@@ -470,7 +511,8 @@
               final count = entry.value;
               final session = sessions.firstWhere(
                 (s) => s.id == sid,
-                orElse: () => Session(id: sid, index: 0, name: 'Unknown', type: 'claude'),
+                orElse: () =>
+                    Session(id: sid, index: 0, name: 'Unknown', type: 'claude'),
               );
               final preview = count == 1
                   ? (crossSessionPreviews[sid] ?? '')
@@ -478,7 +520,9 @@
               ToastManager.show(
                 context,
                 sessionName: session.name,
-                preview: preview.length > 100 ? '${preview.substring(0, 100)}...' : preview,
+                preview: preview.length > 100
+                    ? '${preview.substring(0, 100)}...'
+                    : preview,
                 onTap: () => _switchSession(sid),
               );
             }
@@ -486,7 +530,9 @@
 
           // Clear unread for active session
           if (activeId != null) {
-            final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
+            final counts = Map<String, int>.from(
+              ref.read(unreadCountsProvider),
+            );
             counts.remove(activeId);
             ref.read(unreadCountsProvider.notifier).state = counts;
           }
@@ -523,7 +569,9 @@
       ref.read(activeSessionIdProvider.notifier).state = active.id;
       // Synchronous session switch — no async gap.
       ref.read(messagesProvider.notifier).switchSession(active.id);
-      SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', active.id));
+      SharedPreferences.getInstance().then(
+        (p) => p.setString('activeSessionId', active.id),
+      );
     }
 
     // Session is ready — process any pending messages that arrived before sessions list
@@ -542,6 +590,14 @@
     }
   }
 
+  void _handleProjects(Map<String, dynamic> msg) {
+    final list = msg['projects'] as List<dynamic>?;
+    if (list == null) return;
+    ref.read(projectsProvider.notifier).state = list
+        .map((p) => Project.fromJson(p as Map<String, dynamic>))
+        .toList();
+  }
+
   /// Respond to a pailot_debug_state request from the server.
   /// Reads the in-memory session list and active session from providers
   /// and publishes exactly what the app is currently rendering.
@@ -549,14 +605,18 @@
     final sessions = ref.read(sessionsProvider);
     final activeSessionId = ref.read(activeSessionIdProvider);
 
-    final sessionPayloads = sessions.map((s) => {
-      'sessionId': s.id,
-      'index': s.index,
-      'displayedName': s.name,   // exactly what is shown in the drawer
-      'type': s.type,
-      if (s.kind != null) 'kind': s.kind,
-      'isActive': s.id == activeSessionId,
-    }).toList();
+    final sessionPayloads = sessions
+        .map(
+          (s) => {
+            'sessionId': s.id,
+            'index': s.index,
+            'displayedName': s.name, // exactly what is shown in the drawer
+            'type': s.type,
+            if (s.kind != null) 'kind': s.kind,
+            'isActive': s.id == activeSessionId,
+          },
+        )
+        .toList();
 
     _ws?.publishDebugStateResponse(
       requestId: requestId,
@@ -568,9 +628,7 @@
 
   void _handleIncomingMessage(Map<String, dynamic> msg) {
     final sessionId = msg['sessionId'] as String?;
-    final content = msg['content'] as String? ??
-        msg['text'] as String? ??
-        '';
+    final content = msg['content'] as String? ?? msg['text'] as String? ?? '';
 
     TraceService.instance.addTrace(
       'handleMessage processing type=text',
@@ -597,13 +655,16 @@
       final sessions = ref.read(sessionsProvider);
       final session = sessions.firstWhere(
         (s) => s.id == sessionId,
-        orElse: () => Session(id: sessionId, index: 0, name: 'Unknown', type: 'claude'),
+        orElse: () =>
+            Session(id: sessionId, index: 0, name: 'Unknown', type: 'claude'),
       );
       if (mounted) {
         ToastManager.show(
           context,
           sessionName: session.name,
-          preview: content.length > 100 ? '${content.substring(0, 100)}...' : content,
+          preview: content.length > 100
+              ? '${content.substring(0, 100)}...'
+              : content,
           onTap: () => _switchSession(sessionId),
         );
       }
@@ -620,12 +681,21 @@
 
   Future<void> _handleIncomingVoice(Map<String, dynamic> msg) async {
     final sessionId = msg['sessionId'] as String?;
-    final audioData = msg['audioBase64'] as String? ?? msg['audio'] as String? ?? msg['data'] as String?;
-    final content = msg['content'] as String? ?? msg['transcript'] as String? ?? msg['text'] as String? ?? '';
+    final audioData =
+        msg['audioBase64'] as String? ??
+        msg['audio'] as String? ??
+        msg['data'] as String?;
+    final content =
+        msg['content'] as String? ??
+        msg['transcript'] as String? ??
+        msg['text'] as String? ??
+        '';
     final duration = msg['duration'] as int?;
 
     final message = Message(
-      id: msg['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(),
+      id:
+          msg['id'] as String? ??
+          DateTime.now().millisecondsSinceEpoch.toString(),
       role: MessageRole.assistant,
       type: MessageType.voice,
       content: content,
@@ -641,7 +711,9 @@
       try {
         final dir = await getTemporaryDirectory();
         savedAudioPath = '${dir.path}/voice_${message.id}.m4a';
-        final bytes = base64Decode(audioData.contains(',') ? audioData.split(',').last : audioData);
+        final bytes = base64Decode(
+          audioData.contains(',') ? audioData.split(',').last : audioData,
+        );
         await File(savedAudioPath).writeAsBytes(bytes);
       } catch (_) {
         savedAudioPath = null;
@@ -660,7 +732,9 @@
     );
 
     final currentId = ref.read(messagesProvider.notifier).currentSessionId;
-    _chatLog('voice: sessionId=$sessionId currentId=$currentId audioPath=$savedAudioPath content="${content.substring(0, content.length.clamp(0, 30))}"');
+    _chatLog(
+      'voice: sessionId=$sessionId currentId=$currentId audioPath=$savedAudioPath content="${content.substring(0, content.length.clamp(0, 30))}"',
+    );
     if (sessionId != null && sessionId != currentId) {
       _chatLog('voice: cross-session, appending to store for $sessionId');
       // Synchronous append — no async gap, no race condition.
@@ -670,7 +744,8 @@
       final sessions = ref.read(sessionsProvider);
       final session = sessions.firstWhere(
         (s) => s.id == sessionId,
-        orElse: () => Session(id: sessionId, index: 0, name: 'Unknown', type: 'claude'),
+        orElse: () =>
+            Session(id: sessionId, index: 0, name: 'Unknown', type: 'claude'),
       );
       if (mounted) {
         ToastManager.show(
@@ -687,15 +762,22 @@
     ref.read(isTypingProvider.notifier).state = false;
     _scrollToBottom();
 
-    if (audioData != null && !AudioService.isBackgrounded && !_isCatchingUp && !_isRecording) {
+    if (audioData != null &&
+        !AudioService.isBackgrounded &&
+        !_isCatchingUp &&
+        !_isRecording) {
       setState(() => _playingMessageId = storedMessage.id);
       AudioService.queueBase64(audioData);
     }
   }
 
   void _handleIncomingImage(Map<String, dynamic> msg) {
-    final imageData = msg['imageBase64'] as String? ?? msg['data'] as String? ?? msg['image'] as String?;
-    final content = msg['content'] as String? ?? msg['caption'] as String? ?? '';
+    final imageData =
+        msg['imageBase64'] as String? ??
+        msg['data'] as String? ??
+        msg['image'] as String?;
+    final content =
+        msg['content'] as String? ?? msg['caption'] as String? ?? '';
     final sessionId = msg['sessionId'] as String?;
 
     if (imageData == null) return;
@@ -703,15 +785,20 @@
     // Always update the Navigate screen screenshot provider
     ref.read(latestScreenshotProvider.notifier).state = imageData;
 
-    final isScreenshot = content == 'Screenshot' ||
+    final isScreenshot =
+        content == 'Screenshot' ||
         content == 'Capturing screenshot...' ||
         (msg['type'] == 'screenshot');
 
     if (isScreenshot) {
       // Remove any "Capturing screenshot..." placeholder text messages
-      ref.read(messagesProvider.notifier).removeWhere(
-        (m) => m.role == MessageRole.assistant && m.content == 'Capturing screenshot...',
-      );
+      ref
+          .read(messagesProvider.notifier)
+          .removeWhere(
+            (m) =>
+                m.role == MessageRole.assistant &&
+                m.content == 'Capturing screenshot...',
+          );
 
       // Only add to chat if the Screen button explicitly requested it
       if (!_screenshotForChat) {
@@ -758,7 +845,9 @@
   /// in-place edits). The transcript is updated in-memory if the message is
   /// in the active session. Cross-session transcript updates are a no-op.
   Future<void> _updateTranscriptOnDisk(String messageId, String content) async {
-    _chatLog('transcript: cross-session update for messageId=$messageId — in-memory only (append-only log)');
+    _chatLog(
+      'transcript: cross-session update for messageId=$messageId — in-memory only (append-only log)',
+    );
   }
 
   void _incrementUnread(String sessionId) {
@@ -789,7 +878,9 @@
     ref.read(activeSessionIdProvider.notifier).state = sessionId;
     // Synchronous — no async gap between session switch and incoming messages.
     ref.read(messagesProvider.notifier).switchSession(sessionId);
-    SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', sessionId));
+    SharedPreferences.getInstance().then(
+      (p) => p.setString('activeSessionId', sessionId),
+    );
 
     final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
     counts.remove(sessionId);
@@ -1023,21 +1114,29 @@
       final mime = att['mimeType'] as String;
       final name = att['fileName'] as String? ?? 'file';
       if (mime.startsWith('image/')) {
-        ref.read(messagesProvider.notifier).addMessage(Message.image(
-          role: MessageRole.user,
-          imageBase64: att['data'] as String,
-          content: name,
-          status: MessageStatus.sent,
-        ));
+        ref
+            .read(messagesProvider.notifier)
+            .addMessage(
+              Message.image(
+                role: MessageRole.user,
+                imageBase64: att['data'] as String,
+                content: name,
+                status: MessageStatus.sent,
+              ),
+            );
       } else {
         final size = base64Decode(att['data'] as String).length;
-        ref.read(messagesProvider.notifier).addMessage(Message.text(
-          role: MessageRole.user,
-          content: textCaption.isNotEmpty
-              ? '$textCaption\n📎 $name (${_formatSize(size)})'
-              : '📎 $name (${_formatSize(size)})',
-          status: MessageStatus.sent,
-        ));
+        ref
+            .read(messagesProvider.notifier)
+            .addMessage(
+              Message.text(
+                role: MessageRole.user,
+                content: textCaption.isNotEmpty
+                    ? '$textCaption\n📎 $name (${_formatSize(size)})'
+                    : '📎 $name (${_formatSize(size)})',
+                status: MessageStatus.sent,
+              ),
+            );
       }
     }
 
@@ -1049,14 +1148,25 @@
   String _guessMimeType(String name) {
     final ext = name.split('.').last.toLowerCase();
     const map = {
-      'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
-      'gif': 'image/gif', 'webp': 'image/webp', 'heic': 'image/heic',
-      'pdf': 'application/pdf', 'doc': 'application/msword',
-      'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+      'jpg': 'image/jpeg',
+      'jpeg': 'image/jpeg',
+      'png': 'image/png',
+      'gif': 'image/gif',
+      'webp': 'image/webp',
+      'heic': 'image/heic',
+      'pdf': 'application/pdf',
+      'doc': 'application/msword',
+      'docx':
+          'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
       'xls': 'application/vnd.ms-excel',
-      'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
-      'txt': 'text/plain', 'csv': 'text/csv', 'json': 'application/json',
-      'zip': 'application/zip', 'mp3': 'audio/mpeg', 'mp4': 'video/mp4',
+      'xlsx':
+          'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+      'txt': 'text/plain',
+      'csv': 'text/csv',
+      'json': 'application/json',
+      'zip': 'application/zip',
+      'mp3': 'audio/mpeg',
+      'mp4': 'video/mp4',
     };
     return map[ext] ?? 'application/octet-stream';
   }
@@ -1069,7 +1179,9 @@
 
   void _requestScreenshot() {
     _screenshotForChat = true;
-    _sendCommand('screenshot', {'sessionId': ref.read(activeSessionIdProvider)});
+    _sendCommand('screenshot', {
+      'sessionId': ref.read(activeSessionIdProvider),
+    });
     if (mounted) {
       ScaffoldMessenger.of(context).showSnackBar(
         const SnackBar(
@@ -1169,15 +1281,17 @@
       textCaption = '';
     }
 
-    final attachments = encodedImages.map((b64) =>
-      <String, dynamic>{'data': b64, 'mimeType': 'image/jpeg'}
-    ).toList();
+    final attachments = encodedImages
+        .map((b64) => <String, dynamic>{'data': b64, 'mimeType': 'image/jpeg'})
+        .toList();
 
     // Create the first image message early so we have its ID for transcript reflection
     final firstImageMsg = Message.image(
       role: MessageRole.user,
       imageBase64: encodedImages[0],
-      content: textCaption.isNotEmpty ? textCaption : (voiceB64 != null ? '🎤 ...' : ''),
+      content: textCaption.isNotEmpty
+          ? textCaption
+          : (voiceB64 != null ? '🎤 ...' : ''),
       status: MessageStatus.sent,
     );
 
@@ -1251,9 +1365,16 @@
                   child: const Row(
                     mainAxisAlignment: MainAxisAlignment.center,
                     children: [
-                      Icon(Icons.fiber_manual_record, color: Colors.red, size: 16),
+                      Icon(
+                        Icons.fiber_manual_record,
+                        color: Colors.red,
+                        size: 16,
+                      ),
                       SizedBox(width: 8),
-                      Text('Recording voice caption...', style: TextStyle(fontSize: 16)),
+                      Text(
+                        'Recording voice caption...',
+                        style: TextStyle(fontSize: 16),
+                      ),
                     ],
                   ),
                 ),
@@ -1266,7 +1387,10 @@
                     children: [
                       Icon(Icons.check_circle, color: Colors.green, size: 20),
                       SizedBox(width: 8),
-                      Text('Voice caption recorded', style: TextStyle(fontSize: 16)),
+                      Text(
+                        'Voice caption recorded',
+                        style: TextStyle(fontSize: 16),
+                      ),
                     ],
                   ),
                 ),
@@ -1357,7 +1481,10 @@
               Navigator.pop(ctx);
               ref.read(messagesProvider.notifier).clearMessages();
             },
-            child: const Text('Clear', style: TextStyle(color: AppColors.error)),
+            child: const Text(
+              'Clear',
+              style: TextStyle(color: AppColors.error),
+            ),
           ),
         ],
       ),
@@ -1376,7 +1503,10 @@
     final next = current == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
     ref.read(themeModeProvider.notifier).state = next;
     final prefs = await SharedPreferences.getInstance();
-    await prefs.setString('theme_mode', next == ThemeMode.dark ? 'dark' : 'light');
+    await prefs.setString(
+      'theme_mode',
+      next == ThemeMode.dark ? 'dark' : 'light',
+    );
   }
 
   void _scrollToBottom() {
@@ -1392,7 +1522,46 @@
   }
 
   void _handleNewSession() {
-    _sendCommand('create');
+    // Close the drawer, fetch the latest project list, and present the launcher.
+    Navigator.of(context).pop();
+    _sendCommand('projects');
+    showModalBottomSheet(
+      context: context,
+      isScrollControlled: true,
+      backgroundColor: Theme.of(context).canvasColor,
+      shape: const RoundedRectangleBorder(
+        borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
+      ),
+      builder: (_) => NewSessionSheet(
+        onLaunchProject: (project, rehome) {
+          final activeId = ref.read(activeSessionIdProvider);
+          if (rehome && activeId != null) {
+            _sendCommand('rehome', {
+              'sessionId': activeId,
+              'path': project.path,
+              'name': project.name,
+            });
+          } else {
+            _sendCommand('create', {
+              'project': project.launch,
+              'name': project.name,
+            });
+          }
+        },
+        onLaunchPath: (path, name, rehome) {
+          final activeId = ref.read(activeSessionIdProvider);
+          if (rehome && activeId != null) {
+            _sendCommand('rehome', {
+              'sessionId': activeId,
+              'path': path,
+              'name': name,
+            });
+          } else {
+            _sendCommand('create', {'path': path, 'name': name});
+          }
+        },
+      ),
+    );
   }
 
   /// Called when the user taps an upgrade CTA in the drawer or paywall banner.
@@ -1411,8 +1580,9 @@
   void _handleSessionRemove(Session session) {
     _sendCommand('remove', {'sessionId': session.id});
     final sessions = ref.read(sessionsProvider);
-    ref.read(sessionsProvider.notifier).state =
-        sessions.where((s) => s.id != session.id).toList();
+    ref.read(sessionsProvider.notifier).state = sessions
+        .where((s) => s.id != session.id)
+        .toList();
   }
 
   void _handleSessionReorder(int oldIndex, int newIndex) {
@@ -1428,13 +1598,16 @@
   }
 
   void _saveSessionOrder(List<String> ids) {
-    SharedPreferences.getInstance().then((p) => p.setStringList('sessionOrder', ids));
+    SharedPreferences.getInstance().then(
+      (p) => p.setStringList('sessionOrder', ids),
+    );
   }
 
   /// Apply saved custom order to a server-provided session list.
   /// New sessions (not in saved order) are appended at the end.
   List<Session> _applyCustomOrder(List<Session> sessions) {
-    if (_cachedSessionOrder == null || _cachedSessionOrder!.isEmpty) return sessions;
+    if (_cachedSessionOrder == null || _cachedSessionOrder!.isEmpty)
+      return sessions;
     final order = _cachedSessionOrder!;
     final byId = {for (final s in sessions) s.id: s};
     final ordered = <Session>[];
@@ -1476,136 +1649,145 @@
       behavior: HitTestBehavior.translucent,
       onTap: () => FocusScope.of(context).unfocus(),
       child: Scaffold(
-      key: _scaffoldKey,
-      appBar: AppBar(
-        leading: IconButton(
-          icon: const Icon(Icons.menu),
-          onPressed: () {
-            FocusScope.of(context).unfocus();
-            _scaffoldKey.currentState?.openDrawer();
-          },
-        ),
-        title: Column(
-          crossAxisAlignment: CrossAxisAlignment.center,
-          mainAxisSize: MainAxisSize.min,
-          children: [
-            Text(
-              activeSession?.name ?? 'PAILot',
-              style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
+        key: _scaffoldKey,
+        appBar: AppBar(
+          leading: IconButton(
+            icon: const Icon(Icons.menu),
+            onPressed: () {
+              FocusScope.of(context).unfocus();
+              _scaffoldKey.currentState?.openDrawer();
+            },
+          ),
+          title: Column(
+            crossAxisAlignment: CrossAxisAlignment.center,
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              Text(
+                activeSession?.name ?? 'PAILot',
+                style: const TextStyle(
+                  fontSize: 16,
+                  fontWeight: FontWeight.w600,
+                ),
+              ),
+              if (connectionDetail.isNotEmpty &&
+                  wsStatus != ConnectionStatus.connected)
+                Text(
+                  connectionDetail,
+                  style: TextStyle(fontSize: 11, color: Colors.grey.shade400),
+                ),
+              if (wsStatus == ConnectionStatus.connected &&
+                  ref.watch(connectedViaProvider).isNotEmpty)
+                Text(
+                  'via ${ref.watch(connectedViaProvider)}',
+                  style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
+                ),
+            ],
+          ),
+          actions: [
+            StatusDot(status: wsStatus),
+            const SizedBox(width: 12),
+            IconButton(
+              icon: Icon(
+                Theme.of(context).brightness == Brightness.dark
+                    ? Icons.light_mode
+                    : Icons.dark_mode,
+                size: 20,
+              ),
+              onPressed: _toggleTheme,
             ),
-            if (connectionDetail.isNotEmpty && wsStatus != ConnectionStatus.connected)
-              Text(
-                connectionDetail,
-                style: TextStyle(fontSize: 11, color: Colors.grey.shade400),
-              ),
-            if (wsStatus == ConnectionStatus.connected && ref.watch(connectedViaProvider).isNotEmpty)
-              Text(
-                'via ${ref.watch(connectedViaProvider)}',
-                style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
-              ),
+            IconButton(
+              icon: const Icon(Icons.settings, size: 20),
+              onPressed: () => context.push('/settings'),
+            ),
           ],
         ),
-        actions: [
-          StatusDot(status: wsStatus),
-          const SizedBox(width: 12),
-          IconButton(
-            icon: Icon(
-              Theme.of(context).brightness == Brightness.dark
-                  ? Icons.light_mode
-                  : Icons.dark_mode,
-              size: 20,
+        onDrawerChanged: (isOpened) {
+          if (isOpened) FocusManager.instance.primaryFocus?.unfocus();
+        },
+        drawer: SessionDrawer(
+          sessions: sessions,
+          activeSessionId: activeSession?.id,
+          unreadCounts: unreadCounts,
+          isPro: ref.watch(isProProvider),
+          onSelect: (s) => _switchSession(s.id),
+          onRemove: _handleSessionRemove,
+          onRename: _handleSessionRename,
+          onReorder: _handleSessionReorder,
+          onNewSession: _handleNewSession,
+          onRefresh: _refreshSessions,
+          onUpgrade: _handleUpgrade,
+        ),
+        body: Column(
+          children: [
+            const PaywallBanner(),
+            Expanded(
+              child: ListView.builder(
+                controller: _scrollController,
+                reverse: true,
+                padding: const EdgeInsets.only(top: 8, bottom: 8),
+                itemCount: messages.length + (isTyping ? 1 : 0),
+                itemBuilder: (context, index) {
+                  if (isTyping && index == 0) {
+                    return const TypingIndicator();
+                  }
+
+                  final msgIndex = isTyping
+                      ? messages.length - index
+                      : messages.length - 1 - index;
+
+                  if (msgIndex < 0 || msgIndex >= messages.length) {
+                    return const SizedBox.shrink();
+                  }
+
+                  final message = messages[msgIndex];
+                  return MessageBubble(
+                    message: message,
+                    isPlaying: _playingMessageId == message.id,
+                    onPlay: message.type == MessageType.voice
+                        ? () => _playMessage(message)
+                        : null,
+                    onChainPlay:
+                        message.type == MessageType.voice &&
+                            message.role == MessageRole.assistant
+                        ? () => _chainPlayFrom(message)
+                        : null,
+                    onDelete: () {
+                      ref
+                          .read(messagesProvider.notifier)
+                          .removeMessage(message.id);
+                    },
+                  );
+                },
+              ),
             ),
-            onPressed: _toggleTheme,
-          ),
-          IconButton(
-            icon: const Icon(Icons.settings, size: 20),
-            onPressed: () => context.push('/settings'),
-          ),
-        ],
-      ),
-      onDrawerChanged: (isOpened) {
-        if (isOpened) FocusManager.instance.primaryFocus?.unfocus();
-      },
-      drawer: SessionDrawer(
-        sessions: sessions,
-        activeSessionId: activeSession?.id,
-        unreadCounts: unreadCounts,
-        isPro: ref.watch(isProProvider),
-        onSelect: (s) => _switchSession(s.id),
-        onRemove: _handleSessionRemove,
-        onRename: _handleSessionRename,
-        onReorder: _handleSessionReorder,
-        onNewSession: _handleNewSession,
-        onRefresh: _refreshSessions,
-        onUpgrade: _handleUpgrade,
-      ),
-      body: Column(
-        children: [
-          const PaywallBanner(),
-          Expanded(
-            child: ListView.builder(
-              controller: _scrollController,
-              reverse: true,
-              padding: const EdgeInsets.only(top: 8, bottom: 8),
-              itemCount: messages.length + (isTyping ? 1 : 0),
-              itemBuilder: (context, index) {
-                if (isTyping && index == 0) {
-                  return const TypingIndicator();
-                }
-
-                final msgIndex = isTyping
-                    ? messages.length - index
-                    : messages.length - 1 - index;
-
-                if (msgIndex < 0 || msgIndex >= messages.length) {
-                  return const SizedBox.shrink();
-                }
-
-                final message = messages[msgIndex];
-                return MessageBubble(
-                  message: message,
-                  isPlaying: _playingMessageId == message.id,
-                  onPlay: message.type == MessageType.voice
-                      ? () => _playMessage(message)
-                      : null,
-                  onChainPlay: message.type == MessageType.voice &&
-                          message.role == MessageRole.assistant
-                      ? () => _chainPlayFrom(message)
-                      : null,
-                  onDelete: () {
-                    ref.read(messagesProvider.notifier).removeMessage(message.id);
-                  },
-                );
+            CommandBar(
+              onScreen: _requestScreenshot,
+              onNavigate: _navigateToTerminal,
+              onPhoto: _pickPhoto,
+              onClear: _clearChat,
+              onHelp: inputMode == InputMode.text ? _sendHelp : null,
+              showHelp: inputMode == InputMode.text,
+            ),
+            InputBar(
+              mode: inputMode,
+              isRecording: _isRecording,
+              textController: _textController,
+              onToggleMode: () {
+                ref
+                    .read(inputModeProvider.notifier)
+                    .state = inputMode == InputMode.voice
+                    ? InputMode.text
+                    : InputMode.voice;
               },
+              onRecordStart: _startRecording,
+              onRecordStop: _stopRecording,
+              onRecordCancel: _cancelRecording,
+              onReplay: _replayLast,
+              onSendText: _sendTextMessage,
             ),
-          ),
-          CommandBar(
-            onScreen: _requestScreenshot,
-            onNavigate: _navigateToTerminal,
-            onPhoto: _pickPhoto,
-            onClear: _clearChat,
-            onHelp: inputMode == InputMode.text ? _sendHelp : null,
-            showHelp: inputMode == InputMode.text,
-          ),
-          InputBar(
-            mode: inputMode,
-            isRecording: _isRecording,
-            textController: _textController,
-            onToggleMode: () {
-              ref.read(inputModeProvider.notifier).state =
-                  inputMode == InputMode.voice
-                      ? InputMode.text
-                      : InputMode.voice;
-            },
-            onRecordStart: _startRecording,
-            onRecordStop: _stopRecording,
-            onRecordCancel: _cancelRecording,
-            onReplay: _replayLast,
-            onSendText: _sendTextMessage,
-          ),
-        ],
+          ],
+        ),
       ),
-    ),
     );
   }
 }

--
Gitblit v1.3.1