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/providers/providers.dart       |   34 +
 lib/services/mqtt_service.dart     |  173 +++++++--
 lib/widgets/new_session_sheet.dart |  223 ++++++++++++
 lib/models/project.dart            |   35 +
 lib/screens/chat_screen.dart       |  588 +++++++++++++++++++++-----------
 5 files changed, 800 insertions(+), 253 deletions(-)

diff --git a/lib/models/project.dart b/lib/models/project.dart
new file mode 100644
index 0000000..6c1be3e
--- /dev/null
+++ b/lib/models/project.dart
@@ -0,0 +1,35 @@
+/// A PAI project the user can launch a session in, as sent by the hub's
+/// `projects` control response.
+class Project {
+  /// Display name shown in the picker (e.g. "AIBroker").
+  final String name;
+
+  /// Canonical name the hub uses to launch the project (create {project}).
+  final String launch;
+
+  final String slug;
+  final String path;
+  final int sessions;
+  final String lastActive; // ISO8601 (sorts lexicographically)
+
+  const Project({
+    required this.name,
+    required this.launch,
+    this.slug = '',
+    this.path = '',
+    this.sessions = 0,
+    this.lastActive = '',
+  });
+
+  factory Project.fromJson(Map<String, dynamic> json) {
+    final display = json['name'] as String? ?? 'Project';
+    return Project(
+      name: display,
+      launch: json['launch'] as String? ?? display,
+      slug: json['slug'] as String? ?? '',
+      path: json['path'] as String? ?? '',
+      sessions: json['sessions'] as int? ?? 0,
+      lastActive: json['lastActive'] as String? ?? '',
+    );
+  }
+}
diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart
index ebfbb50..b5a35e2 100644
--- a/lib/providers/providers.dart
+++ b/lib/providers/providers.dart
@@ -6,6 +6,7 @@
 import 'package:flutter_secure_storage/flutter_secure_storage.dart';
 
 import '../models/message.dart';
+import '../models/project.dart';
 import '../models/server_config.dart';
 import '../models/session.dart';
 import '../services/message_store.dart';
@@ -25,8 +26,8 @@
 
 final serverConfigProvider =
     StateNotifierProvider<ServerConfigNotifier, ServerConfig?>((ref) {
-  return ServerConfigNotifier();
-});
+      return ServerConfigNotifier();
+    });
 
 class ServerConfigNotifier extends StateNotifier<ServerConfig?> {
   ServerConfigNotifier() : super(null) {
@@ -60,8 +61,9 @@
 
 // --- Connection Status ---
 
-final wsStatusProvider =
-    StateProvider<ConnectionStatus>((ref) => ConnectionStatus.disconnected);
+final wsStatusProvider = StateProvider<ConnectionStatus>(
+  (ref) => ConnectionStatus.disconnected,
+);
 
 final connectionDetailProvider = StateProvider<String>((ref) => '');
 final connectedViaProvider = StateProvider<String>((ref) => '');
@@ -83,12 +85,17 @@
   }
 });
 
+// --- Projects (PAI project launcher) ---
+
+final projectsProvider = StateProvider<List<Project>>((ref) => []);
+
 // --- Messages ---
 
-final messagesProvider =
-    StateNotifierProvider<MessagesNotifier, List<Message>>((ref) {
-  return MessagesNotifier(ref);
-});
+final messagesProvider = StateNotifierProvider<MessagesNotifier, List<Message>>(
+  (ref) {
+    return MessagesNotifier(ref);
+  },
+);
 
 class MessagesNotifier extends StateNotifier<List<Message>> {
   MessagesNotifier(this.ref) : super([]);
@@ -103,7 +110,9 @@
   void switchSession(String sessionId) {
     if (_currentSessionId == sessionId) {
       TraceService.instance.addTrace(
-          'switchSession SKIP', 'already on ${sessionId.substring(0, 8)}');
+        'switchSession SKIP',
+        'already on ${sessionId.substring(0, 8)}',
+      );
       return;
     }
     TraceService.instance.addTrace(
@@ -173,8 +182,7 @@
 
 // --- Unread Counts ---
 
-final unreadCountsProvider =
-    StateProvider<Map<String, int>>((ref) => {});
+final unreadCountsProvider = StateProvider<Map<String, int>>((ref) => {});
 
 // --- Input Mode ---
 
@@ -187,7 +195,9 @@
 // Holds the bridge between NavigateScreen and ChatScreen's MQTT service.
 // ChatScreen sets this when MQTT is initialized; NavigateScreen reads it.
 // Using a Riverpod provider eliminates the stale static reference risk.
-final navigateNotifierProvider = StateProvider<NavigateNotifier?>((ref) => null);
+final navigateNotifierProvider = StateProvider<NavigateNotifier?>(
+  (ref) => null,
+);
 
 // --- Pro / Purchase Status ---
 
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,
-          ),
-        ],
+          ],
+        ),
       ),
-    ),
     );
   }
 }
diff --git a/lib/services/mqtt_service.dart b/lib/services/mqtt_service.dart
index 72632aa..5cf1c62 100644
--- a/lib/services/mqtt_service.dart
+++ b/lib/services/mqtt_service.dart
@@ -20,12 +20,7 @@
 import 'wol_service.dart';
 
 /// Connection status for the MQTT client.
-enum ConnectionStatus {
-  disconnected,
-  connecting,
-  connected,
-  reconnecting,
-}
+enum ConnectionStatus { disconnected, connecting, connected, reconnecting }
 
 // Debug log — writes to file only in debug builds, always prints via debugPrint.
 // Also adds entries to TraceService so they appear in the trace log viewer.
@@ -77,10 +72,12 @@
 
   // Callbacks
   void Function(ConnectionStatus status)? onStatusChanged;
-  void Function(String detail)? onStatusDetail; // "Probing local...", "Scanning network..."
+  void Function(String detail)?
+  onStatusDetail; // "Probing local...", "Scanning network..."
   String? connectedHost; // The host we're currently connected to
   String? connectedVia; // "Local", "VPN", "Remote", "Bonjour", "Scan"
   void Function(Map<String, dynamic> message)? onMessage;
+
   /// Called when the server sends a debug_state_request on pailot/control/out.
   /// The handler should read current session state and call [publishDebugStateResponse].
   void Function(String requestId)? onDebugStateRequest;
@@ -112,6 +109,29 @@
     }
     _clientId = id;
     return id;
+  }
+
+  // The host that last connected successfully, persisted across app restarts so
+  // a cold start (iOS killed the backgrounded app) can reconnect fast instead of
+  // re-running the LAN race + network scan when only the VPN host is reachable.
+  static const String _kLastHostKey = 'mqtt_last_good_host';
+
+  Future<void> _saveLastGoodHost(String host) async {
+    try {
+      final prefs = await SharedPreferences.getInstance();
+      await prefs.setString(_kLastHostKey, host);
+    } catch (_) {}
+  }
+
+  Future<String?> _loadLastGoodHost() async {
+    try {
+      final h = (await SharedPreferences.getInstance()).getString(
+        _kLastHostKey,
+      );
+      return (h != null && h.isNotEmpty) ? h : null;
+    } catch (_) {
+      return null;
+    }
   }
 
   /// Force reconnect — disconnect and reconnect to last known host.
@@ -168,13 +188,41 @@
 
     final clientId = await _getClientId();
 
+    // Phase 0: Fast path — try the last host that worked (persisted across app
+    // restarts) before racing all hosts or scanning. On cellular/Tailscale the
+    // LAN host and mDNS are unreachable, so this avoids the slow scan every cold
+    // start. If it's stale/unreachable it times out quickly and we fall through.
+    final lastGood = await _loadLastGoodHost();
+    if (lastGood != null && !_intentionalClose) {
+      onStatusDetail?.call('Reconnecting…');
+      _mqttLog('MQTT: fast path — trying last-good host $lastGood');
+      if (await _tryConnect(lastGood, clientId, timeout: 2500)) {
+        if (lastGood == config.localHost) {
+          connectedVia = 'Local';
+        } else if (lastGood == config.vpnHost) {
+          connectedVia = 'VPN';
+        } else if (lastGood == config.host) {
+          connectedVia = 'Remote';
+        } else {
+          connectedVia = 'Reconnected';
+        }
+        _mqttLog('MQTT: fast path connected via $connectedVia');
+        return;
+      }
+    }
+
     // Phase 1: Race configured hosts (fast — just TLS probe, ~1s each)
     final hosts = <String>[];
-    if (config.localHost != null && config.localHost!.isNotEmpty) hosts.add(config.localHost!);
-    if (_lastDiscoveredHost != null && !hosts.contains(_lastDiscoveredHost)) hosts.add(_lastDiscoveredHost!);
-    if (config.vpnHost != null && config.vpnHost!.isNotEmpty) hosts.add(config.vpnHost!);
+    if (config.localHost != null && config.localHost!.isNotEmpty)
+      hosts.add(config.localHost!);
+    if (_lastDiscoveredHost != null && !hosts.contains(_lastDiscoveredHost))
+      hosts.add(_lastDiscoveredHost!);
+    if (config.vpnHost != null && config.vpnHost!.isNotEmpty)
+      hosts.add(config.vpnHost!);
     if (config.host.isNotEmpty) hosts.add(config.host);
-    _mqttLog('MQTT: racing ${hosts.length} configured hosts: ${hosts.join(", ")}');
+    _mqttLog(
+      'MQTT: racing ${hosts.length} configured hosts: ${hosts.join(", ")}',
+    );
     onStatusDetail?.call('Connecting...');
 
     // Race: first probe to succeed wins, don't wait for others
@@ -250,7 +298,9 @@
   /// Discover AIBroker on local network via Bonjour/mDNS.
   /// Falls back to subnet scan if Bonjour fails (iOS blocks mDNS on Personal Hotspot).
   /// Returns the IP address or null if not found within timeout.
-  Future<String?> _discoverViaMdns({Duration timeout = const Duration(seconds: 3)}) async {
+  Future<String?> _discoverViaMdns({
+    Duration timeout = const Duration(seconds: 3),
+  }) async {
     // Try Bonjour first
     try {
       final discovery = BonsoirDiscovery(type: '_mqtt._tcp');
@@ -263,7 +313,9 @@
         switch (event) {
           case BonsoirDiscoveryServiceResolvedEvent():
             final ip = event.service.host;
-            _mqttLog('MQTT: Bonjour resolved: ${event.service.name} at $ip:${event.service.port}');
+            _mqttLog(
+              'MQTT: Bonjour resolved: ${event.service.name} at $ip:${event.service.port}',
+            );
             if (ip != null && ip.isNotEmpty && !completer.isCompleted) {
               completer.complete(ip);
             }
@@ -297,7 +349,9 @@
   Future<String?> _scanSubnetForMqtt() async {
     try {
       // Get device's own IP to determine the subnet
-      final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
+      final interfaces = await NetworkInterface.list(
+        type: InternetAddressType.IPv4,
+      );
       for (final iface in interfaces) {
         for (final addr in iface.addresses) {
           final parts = addr.address.split('.');
@@ -319,7 +373,10 @@
               futures.add(_probeHost(probe, config.port));
             }
             final results = await Future.wait(futures);
-            final found = results.firstWhere((r) => r != null, orElse: () => null);
+            final found = results.firstWhere(
+              (r) => r != null,
+              orElse: () => null,
+            );
             if (found != null) {
               _mqttLog('MQTT: subnet scan found broker at $found');
               return found;
@@ -342,7 +399,9 @@
     final prefs = await SharedPreferences.getInstance();
     _trustedFingerprint = prefs.getString('trustedCertFingerprint');
     if (_trustedFingerprint != null) {
-      _mqttLog('TOFU: loaded trusted fingerprint: ${_trustedFingerprint!.substring(0, 16)}...');
+      _mqttLog(
+        'TOFU: loaded trusted fingerprint: ${_trustedFingerprint!.substring(0, 16)}...',
+      );
     }
   }
 
@@ -365,7 +424,9 @@
       SharedPreferences.getInstance().then((prefs) {
         prefs.setString('trustedCertFingerprint', fingerprint);
       });
-      _mqttLog('TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...');
+      _mqttLog(
+        'TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...',
+      );
       return true;
     }
 
@@ -374,7 +435,9 @@
     }
 
     // Fingerprint mismatch — possible MITM or server reinstall
-    _mqttLog('TOFU: CERT MISMATCH! Expected ${_trustedFingerprint!.substring(0, 16)}... got ${fingerprint.substring(0, 16)}...');
+    _mqttLog(
+      'TOFU: CERT MISMATCH! Expected ${_trustedFingerprint!.substring(0, 16)}... got ${fingerprint.substring(0, 16)}...',
+    );
     // Reject the connection. User must reset trust in settings.
     return false;
   }
@@ -404,11 +467,17 @@
     }
   }
 
-  Future<bool> _tryConnect(String host, String clientId, {int timeout = 5000}) async {
+  Future<bool> _tryConnect(
+    String host,
+    String clientId, {
+    int timeout = 5000,
+  }) async {
     try {
       final client = MqttServerClient.withPort(host, clientId, config.port);
-      client.keepAlivePeriod = 120; // 2 min — iOS throttles bg network, short keepalive causes drops
-      client.autoReconnect = false; // Don't auto-reconnect during trial — enable after success
+      client.keepAlivePeriod =
+          120; // 2 min — iOS throttles bg network, short keepalive causes drops
+      client.autoReconnect =
+          false; // Don't auto-reconnect during trial — enable after success
       client.connectTimeoutPeriod = timeout;
       // client.maxConnectionAttempts is final — can't set it
       client.logging(on: false);
@@ -440,7 +509,9 @@
       // Set _client BEFORE connect() so _onConnected can subscribe
       _client = client;
 
-      _mqttLog('MQTT: connecting to $host:${config.port} as $clientId (timeout=${timeout}ms)');
+      _mqttLog(
+        'MQTT: connecting to $host:${config.port} as $clientId (timeout=${timeout}ms)',
+      );
       final result = await client.connect().timeout(
         Duration(milliseconds: timeout + 1000),
         onTimeout: () {
@@ -453,6 +524,8 @@
         // Don't use autoReconnect — it has no backoff and causes tight reconnect loops.
         // We handle reconnection manually in _onDisconnected with exponential backoff.
         _reconnectAttempt = 0;
+        connectedHost = host;
+        _saveLastGoodHost(host); // remember for a fast reconnect after restart
         return true;
       }
       _client = null;
@@ -471,12 +544,17 @@
     // STABLE for 10+ seconds. This prevents flap loops where each brief connect
     // resets the backoff and we hammer the server every 5s forever.
     _stabilityTimer?.cancel();
-    _stabilityTimer = Timer(const Duration(milliseconds: _stabilityThresholdMs), () {
-      if (_status == ConnectionStatus.connected) {
-        _mqttLog('MQTT: connection stable for ${_stabilityThresholdMs}ms — resetting backoff');
-        _reconnectAttempt = 0;
-      }
-    });
+    _stabilityTimer = Timer(
+      const Duration(milliseconds: _stabilityThresholdMs),
+      () {
+        if (_status == ConnectionStatus.connected) {
+          _mqttLog(
+            'MQTT: connection stable for ${_stabilityThresholdMs}ms — resetting backoff',
+          );
+          _reconnectAttempt = 0;
+        }
+      },
+    );
     _setStatus(ConnectionStatus.connected);
     _subscribe();
     _listenMessages();
@@ -501,9 +579,14 @@
   void _scheduleReconnect() {
     _reconnectTimer?.cancel();
     // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s cap
-    final delayMs = (1000 * (1 << _reconnectAttempt)).clamp(1000, _maxReconnectDelay);
+    final delayMs = (1000 * (1 << _reconnectAttempt)).clamp(
+      1000,
+      _maxReconnectDelay,
+    );
     _reconnectAttempt++;
-    _mqttLog('MQTT: scheduling reconnect in ${delayMs}ms (attempt $_reconnectAttempt)');
+    _mqttLog(
+      'MQTT: scheduling reconnect in ${delayMs}ms (attempt $_reconnectAttempt)',
+    );
     _reconnectTimer = Timer(Duration(milliseconds: delayMs), () async {
       if (_intentionalClose || _status == ConnectionStatus.connected) return;
       final host = connectedHost ?? _lastDiscoveredHost;
@@ -675,7 +758,9 @@
   /// Publish raw bytes to a topic. Used by TraceService for log streaming.
   void publishRaw(String topic, Uint8Buffer payload, MqttQos qos) {
     final client = _client;
-    if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) return;
+    if (client == null ||
+        client.connectionStatus?.state != MqttConnectionState.connected)
+      return;
     try {
       client.publishMessage(topic, qos, payload);
     } catch (_) {}
@@ -684,7 +769,8 @@
   /// Publish a JSON payload to an MQTT topic.
   void _publish(String topic, Map<String, dynamic> payload, MqttQos qos) {
     final client = _client;
-    if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) {
+    if (client == null ||
+        client.connectionStatus?.state != MqttConnectionState.connected) {
       onError?.call('Not connected');
       return;
     }
@@ -718,7 +804,9 @@
       if (platform != null) 'platform': platform,
       'ts': DateTime.now().millisecondsSinceEpoch,
     }, MqttQos.atLeastOnce);
-    _mqttLog('debug_state_response sent for requestId=$requestId sessions=${sessions.length}');
+    _mqttLog(
+      'debug_state_response sent for requestId=$requestId sessions=${sessions.length}',
+    );
   }
 
   /// Send a message — routes to the appropriate MQTT topic based on content.
@@ -774,8 +862,10 @@
         'type': 'bundle',
         'sessionId': sessionId,
         'caption': message['caption'] ?? '',
-        if (message['audioBase64'] != null) 'audioBase64': message['audioBase64'],
-        if (message['voiceMessageId'] != null) 'voiceMessageId': message['voiceMessageId'],
+        if (message['audioBase64'] != null)
+          'audioBase64': message['audioBase64'],
+        if (message['voiceMessageId'] != null)
+          'voiceMessageId': message['voiceMessageId'],
         'attachments': message['attachments'] ?? [],
         'ts': _now(),
       }, MqttQos.atLeastOnce);
@@ -830,13 +920,20 @@
   /// no MQTT clients are connected (app is backgrounded or offline).
   void sendDeviceToken(String token) {
     final client = _client;
-    if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) {
+    if (client == null ||
+        client.connectionStatus?.state != MqttConnectionState.connected) {
       return;
     }
     try {
       final builder = MqttClientPayloadBuilder();
-      builder.addString('{"token":"$token","ts":${DateTime.now().millisecondsSinceEpoch}}');
-      client.publishMessage('pailot/device/token', MqttQos.atLeastOnce, builder.payload!);
+      builder.addString(
+        '{"token":"$token","ts":${DateTime.now().millisecondsSinceEpoch}}',
+      );
+      client.publishMessage(
+        'pailot/device/token',
+        MqttQos.atLeastOnce,
+        builder.payload!,
+      );
       _mqttLog('Push: device token published to pailot/device/token');
     } catch (e) {
       _mqttLog('Push: failed to publish device token: $e');
diff --git a/lib/widgets/new_session_sheet.dart b/lib/widgets/new_session_sheet.dart
new file mode 100644
index 0000000..f2407d7
--- /dev/null
+++ b/lib/widgets/new_session_sheet.dart
@@ -0,0 +1,223 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../models/project.dart';
+import '../providers/providers.dart';
+
+/// Bottom sheet for starting a new session: search your PAI projects (like PAI
+/// search), or open the home directory / any arbitrary directory.
+///
+/// The list comes from [projectsProvider], which the chat screen populates by
+/// sending the `projects` command when this sheet opens.
+class NewSessionSheet extends ConsumerStatefulWidget {
+  /// Open a project. rehome=false → new session; rehome=true → re-home the
+  /// current tab to this project's directory.
+  final void Function(Project project, bool rehome) onLaunchProject;
+
+  /// Open an arbitrary directory ("~" for home). rehome as above.
+  final void Function(String path, String name, bool rehome) onLaunchPath;
+
+  const NewSessionSheet({
+    super.key,
+    required this.onLaunchProject,
+    required this.onLaunchPath,
+  });
+
+  @override
+  ConsumerState<NewSessionSheet> createState() => _NewSessionSheetState();
+}
+
+class _NewSessionSheetState extends ConsumerState<NewSessionSheet> {
+  final _searchController = TextEditingController();
+  String _query = '';
+  bool _rehome = false; // false = new session, true = re-home the current tab
+
+  @override
+  void dispose() {
+    _searchController.dispose();
+    super.dispose();
+  }
+
+  List<Project> _filtered(List<Project> projects) {
+    final list = [...projects]
+      ..sort(
+        (a, b) => b.lastActive.compareTo(a.lastActive),
+      ); // most recent first
+    final q = _query.trim().toLowerCase();
+    if (q.isEmpty) return list;
+    return list
+        .where(
+          (p) =>
+              p.name.toLowerCase().contains(q) ||
+              p.slug.toLowerCase().contains(q) ||
+              p.path.toLowerCase().contains(q),
+        )
+        .toList();
+  }
+
+  void _launchProject(Project p) {
+    Navigator.of(context).pop();
+    widget.onLaunchProject(p, _rehome);
+  }
+
+  void _launchPath(String path, String name) {
+    Navigator.of(context).pop();
+    widget.onLaunchPath(path, name, _rehome);
+  }
+
+  Future<void> _promptCustomDir() async {
+    final controller = TextEditingController();
+    final dir = await showDialog<String>(
+      context: context,
+      builder: (ctx) => AlertDialog(
+        title: const Text('Open a directory'),
+        content: TextField(
+          controller: controller,
+          autofocus: true,
+          decoration: const InputDecoration(
+            hintText: '/Users/you/dev/apps/youdrill',
+          ),
+          onSubmitted: (v) => Navigator.pop(ctx, v),
+        ),
+        actions: [
+          TextButton(
+            onPressed: () => Navigator.pop(ctx),
+            child: const Text('Cancel'),
+          ),
+          TextButton(
+            onPressed: () => Navigator.pop(ctx, controller.text),
+            child: const Text('Open'),
+          ),
+        ],
+      ),
+    );
+    controller.dispose();
+    final path = dir?.trim() ?? '';
+    if (path.isEmpty) return;
+    final parts = path.split('/').where((s) => s.isNotEmpty).toList();
+    _launchPath(path, parts.isNotEmpty ? parts.last : 'Session');
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final projects = ref.watch(projectsProvider);
+    final filtered = _filtered(projects);
+
+    return Padding(
+      padding: EdgeInsets.only(
+        bottom: MediaQuery.of(context).viewInsets.bottom,
+      ),
+      child: DraggableScrollableSheet(
+        expand: false,
+        initialChildSize: 0.7,
+        minChildSize: 0.4,
+        maxChildSize: 0.92,
+        builder: (context, scrollController) {
+          return Column(
+            children: [
+              // Grabber
+              Container(
+                width: 40,
+                height: 4,
+                margin: const EdgeInsets.symmetric(vertical: 10),
+                decoration: BoxDecoration(
+                  color: Colors.grey.withAlpha(120),
+                  borderRadius: BorderRadius.circular(2),
+                ),
+              ),
+              Padding(
+                padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
+                child: SegmentedButton<bool>(
+                  segments: const [
+                    ButtonSegment(
+                      value: false,
+                      label: Text('New session'),
+                      icon: Icon(Icons.add, size: 18),
+                    ),
+                    ButtonSegment(
+                      value: true,
+                      label: Text('Switch this tab'),
+                      icon: Icon(Icons.swap_horiz, size: 18),
+                    ),
+                  ],
+                  selected: {_rehome},
+                  showSelectedIcon: false,
+                  onSelectionChanged: (s) => setState(() => _rehome = s.first),
+                ),
+              ),
+              Padding(
+                padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
+                child: TextField(
+                  controller: _searchController,
+                  autofocus: true,
+                  textInputAction: TextInputAction.search,
+                  decoration: InputDecoration(
+                    hintText: 'Search projects…',
+                    prefixIcon: const Icon(Icons.search),
+                    border: OutlineInputBorder(
+                      borderRadius: BorderRadius.circular(12),
+                    ),
+                    isDense: true,
+                  ),
+                  onChanged: (v) => setState(() => _query = v),
+                ),
+              ),
+              Expanded(
+                child: ListView(
+                  controller: scrollController,
+                  children: [
+                    ListTile(
+                      leading: const Icon(Icons.home_outlined),
+                      title: const Text('Home directory'),
+                      subtitle: const Text('New session in ~'),
+                      onTap: () => _launchPath('~', 'Home'),
+                    ),
+                    ListTile(
+                      leading: const Icon(Icons.folder_open_outlined),
+                      title: const Text('Open a directory…'),
+                      subtitle: const Text('Start in any path'),
+                      onTap: _promptCustomDir,
+                    ),
+                    const Divider(height: 1),
+                    if (projects.isEmpty)
+                      const Padding(
+                        padding: EdgeInsets.all(24),
+                        child: Center(child: Text('Loading projects…')),
+                      )
+                    else if (filtered.isEmpty)
+                      const Padding(
+                        padding: EdgeInsets.all(24),
+                        child: Center(child: Text('No matching projects')),
+                      )
+                    else
+                      ...filtered.map(
+                        (p) => ListTile(
+                          leading: const Icon(Icons.folder_special_outlined),
+                          title: Text(p.name),
+                          subtitle: Text(
+                            p.path,
+                            maxLines: 1,
+                            overflow: TextOverflow.ellipsis,
+                          ),
+                          trailing: p.sessions > 0
+                              ? Text(
+                                  '${p.sessions}',
+                                  style: TextStyle(
+                                    color: Colors.grey.withAlpha(180),
+                                    fontSize: 13,
+                                  ),
+                                )
+                              : null,
+                          onTap: () => _launchProject(p),
+                        ),
+                      ),
+                  ],
+                ),
+              ),
+            ],
+          );
+        },
+      ),
+    );
+  }
+}

--
Gitblit v1.3.1