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