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