@tduncan / Cortana / commits / c4a3c79

feat: add model unloading, improve conversation persistence, and refine UI components

abdulmominsakib committed Apr 4, 2026 at 18:26 UTC c4a3c7976c0fc19522b866341ab71d95e7774c43
6 files changed +141 -49
lib/features/chat/providers/chat_providers.dart
+1
@@ -730,6 +730,7 @@ class ChatNotifier extends Notifier<ChatState> {
730 void cancelStream() {
731 _streamSubscription?.cancel();
732 _streamSubscription = null;
733 + ref.read(chatServiceProvider).cancelStream();
734 state = state.copyWith(isStreaming: false, clearStreaming: true);
735 }
736
lib/features/chat/views/chat_screen.dart
+40 -24
@@ -776,12 +776,16 @@ class _EmptyStateState extends State<_EmptyState>
776 color: theme.colorScheme.primary,
777 ),
778 const SizedBox(width: 8),
779 - Text(
780 - widget.selectedModel?.displayName ?? 'Select Model',
781 - style: TextStyle(
782 - fontSize: 15,
783 - fontWeight: FontWeight.w600,
784 - color: isDark ? Colors.white : Colors.black,
779 + Flexible(
780 + child: Text(
781 + widget.selectedModel?.displayName ?? 'Select Model',
782 + style: TextStyle(
783 + fontSize: 15,
784 + fontWeight: FontWeight.w600,
785 + color: isDark ? Colors.white : Colors.black,
786 + ),
787 + maxLines: 2,
788 + overflow: TextOverflow.ellipsis,
789 ),
790 ),
791 const SizedBox(width: 4),
@@ -820,12 +824,16 @@ class _EmptyStateState extends State<_EmptyState>
824 style: const TextStyle(fontSize: 18),
825 ),
826 const SizedBox(width: 8),
823 - Text(
824 - widget.selectedPersona!.name,
825 - style: TextStyle(
826 - fontSize: 15,
827 - fontWeight: FontWeight.w600,
828 - color: isDark ? Colors.white : Colors.black,
827 + Flexible(
828 + child: Text(
829 + widget.selectedPersona!.name,
830 + style: TextStyle(
831 + fontSize: 15,
832 + fontWeight: FontWeight.w600,
833 + color: isDark ? Colors.white : Colors.black,
834 + ),
835 + maxLines: 2,
836 + overflow: TextOverflow.ellipsis,
837 ),
838 ),
839 ] else ...[
@@ -835,12 +843,16 @@ class _EmptyStateState extends State<_EmptyState>
843 color: theme.colorScheme.primary,
844 ),
845 const SizedBox(width: 8),
838 - Text(
839 - 'Select Persona',
840 - style: TextStyle(
841 - fontSize: 15,
842 - fontWeight: FontWeight.w600,
843 - color: isDark ? Colors.white : Colors.black,
846 + Flexible(
847 + child: Text(
848 + 'Select Persona',
849 + style: TextStyle(
850 + fontSize: 15,
851 + fontWeight: FontWeight.w600,
852 + color: isDark ? Colors.white : Colors.black,
853 + ),
854 + maxLines: 2,
855 + overflow: TextOverflow.ellipsis,
856 ),
857 ),
858 ],
@@ -1316,12 +1328,16 @@ class _PersonaIndicator extends StatelessWidget {
1328 children: [
1329 Text(persona.emoji, style: const TextStyle(fontSize: 16)),
1330 const SizedBox(width: 6),
1319 - Text(
1320 - persona.name,
1321 - style: TextStyle(
1322 - fontSize: 13,
1323 - fontWeight: FontWeight.w600,
1324 - color: theme.colorScheme.primary,
1331 + Flexible(
1332 + child: Text(
1333 + persona.name,
1334 + style: TextStyle(
1335 + fontSize: 13,
1336 + fontWeight: FontWeight.w600,
1337 + color: theme.colorScheme.primary,
1338 + ),
1339 + maxLines: 2,
1340 + overflow: TextOverflow.ellipsis,
1341 ),
1342 ),
1343 ],
lib/features/chat/views/components/chat_input_bar.dart
+1 -1
@@ -275,7 +275,7 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar>
275 color: isDark ? Colors.white : Colors.black87,
276 ),
277 decoration: InputDecoration(
278 - hintText: 'Reply',
278 + hintText: 'Ask anything',
279 hintStyle: TextStyle(
280 color: isDark ? Colors.white38 : Colors.black38,
281 fontSize: 15,
lib/features/conversations/data/models/conversation.dart
+4 -2
@@ -58,11 +58,13 @@ class Conversation extends HiveObject {
58 DateTime? updatedAt,
59 bool? isPinned,
60 String? personaId,
61 + bool clearPersona = false,
62 String? serverId,
63 String? modelId,
64 int? messageCount,
65 String? lastMessagePreview,
66 String? systemPrompt,
67 + bool clearSystemPrompt = false,
68 }) {
69 return Conversation(
70 id: id ?? this.id,
@@ -70,12 +72,12 @@ class Conversation extends HiveObject {
72 createdAt: createdAt ?? this.createdAt,
73 updatedAt: updatedAt ?? this.updatedAt,
74 isPinned: isPinned ?? this.isPinned,
73 - personaId: personaId ?? this.personaId,
75 + personaId: clearPersona ? null : (personaId ?? this.personaId),
76 serverId: serverId ?? this.serverId,
77 modelId: modelId ?? this.modelId,
78 messageCount: messageCount ?? this.messageCount,
79 lastMessagePreview: lastMessagePreview ?? this.lastMessagePreview,
78 - systemPrompt: systemPrompt ?? this.systemPrompt,
80 + systemPrompt: clearSystemPrompt ? null : (systemPrompt ?? this.systemPrompt),
81 );
82 }
83 }
lib/features/conversations/providers/conversation_providers.dart
+24 -2
@@ -125,7 +125,9 @@ class ConversationsNotifier extends Notifier<List<Conversation>> {
125 if (conversation != null) {
126 final updated = conversation.copyWith(
127 personaId: personaId,
128 + clearPersona: personaId == null,
129 systemPrompt: systemPrompt,
130 + clearSystemPrompt: systemPrompt == null,
131 updatedAt: DateTime.now(),
132 );
133 await box.put(id, updated);
@@ -154,14 +156,34 @@ final activeConversationProvider =
156 });
157
158 class ActiveConversationNotifier extends Notifier<Conversation?> {
159 + String? _activeConversationId;
160 +
161 @override
162 Conversation? build() {
163 final conversations = ref.watch(conversationsProvider);
160 - if (conversations.isEmpty) return null;
161 - return conversations.first;
164 + if (conversations.isEmpty) {
165 + _activeConversationId = null;
166 + return null;
167 + }
168 +
169 + // Attempt to maintain the currently active conversation
170 + if (_activeConversationId != null) {
171 + final conversation = conversations
172 + .where((c) => c.id == _activeConversationId)
173 + .firstOrNull;
174 + if (conversation != null) {
175 + return conversation;
176 + }
177 + }
178 +
179 + // Default to the first available conversation if none active or active one was deleted
180 + final first = conversations.first;
181 + _activeConversationId = first.id;
182 + return first;
183 }
184
185 void setActiveConversation(Conversation? conversation) {
186 + _activeConversationId = conversation?.id;
187 if (conversation != null) {
188 final conversations = ref.read(conversationsProvider);
189 final index = conversations.indexWhere((c) => c.id == conversation.id);
lib/features/models/screens/model_picker_sheet.dart
+71 -20
@@ -346,6 +346,39 @@ class _ModelList extends ConsumerWidget {
346 Navigator.pop(context);
347 }
348 },
349 + onUnload: () async {
350 + final activeServer = ref.read(activeServerProvider);
351 + if (activeServer == null) return;
352 +
353 + try {
354 + final apiService = ref.read(serverApiServiceProvider);
355 + await apiService.unloadModel(activeServer, model.id);
356 + ref.invalidate(loadedModelsProvider(activeServer));
357 +
358 + // If the model being unloaded is the currently selected one, clear it
359 + final selectedModel = ref.read(selectedModelProvider);
360 + if (selectedModel?.id == model.id) {
361 + ref.read(selectedModelProvider.notifier).clear();
362 + }
363 +
364 + if (context.mounted) {
365 + ScaffoldMessenger.of(context).showSnackBar(
366 + SnackBar(
367 + content: Text('${model.name} unloaded successfully'),
368 + ),
369 + );
370 + }
371 + } catch (e) {
372 + if (context.mounted) {
373 + ScaffoldMessenger.of(context).showSnackBar(
374 + SnackBar(
375 + content: Text('Failed to unload model: $e'),
376 + backgroundColor: Colors.red,
377 + ),
378 + );
379 + }
380 + }
381 + },
382 );
383 },
384 );
@@ -361,6 +394,7 @@ class _ModelTile extends StatelessWidget {
394 required this.isLoaded,
395 required this.isDark,
396 required this.onTap,
397 + this.onUnload,
398 });
399
400 final ModelInfo model;
@@ -368,6 +402,7 @@ class _ModelTile extends StatelessWidget {
402 final bool isLoaded;
403 final bool isDark;
404 final VoidCallback onTap;
405 + final Future<void> Function()? onUnload;
406
407 @override
408 Widget build(BuildContext context) {
@@ -396,28 +431,20 @@ class _ModelTile extends StatelessWidget {
431 children: [
432 Row(
433 children: [
399 - Text(
400 - model.displayName,
401 - style: TextStyle(
402 - fontSize: 15,
403 - fontWeight: isSelected
404 - ? FontWeight.w600
405 - : FontWeight.w500,
406 - color: isDark ? Colors.white : Colors.black,
407 - ),
408 - maxLines: 1,
409 - overflow: TextOverflow.ellipsis,
410 - ),
411 - if (isLoaded)
412 - Container(
413 - width: 8,
414 - height: 8,
415 - margin: const EdgeInsets.only(left: 8),
416 - decoration: const BoxDecoration(
417 - color: Color(0xFF4CAF50),
418 - shape: BoxShape.circle,
434 + Expanded(
435 + child: Text(
436 + model.displayName,
437 + style: TextStyle(
438 + fontSize: 15,
439 + fontWeight: isSelected
440 + ? FontWeight.w600
441 + : FontWeight.w500,
442 + color: isDark ? Colors.white : Colors.black,
443 ),
444 + maxLines: 1,
445 + overflow: TextOverflow.ellipsis,
446 ),
447 + ),
448 ],
449 ),
450 const SizedBox(height: 4),
@@ -450,6 +477,30 @@ class _ModelTile extends StatelessWidget {
477 ],
478 ),
479 ),
480 + if (isLoaded) ...[
481 + Container(
482 + width: 15,
483 + height: 15,
484 + decoration: const BoxDecoration(
485 + color: Color(0xFF4CAF50),
486 + shape: BoxShape.circle,
487 + ),
488 + ),
489 + ],
490 + if (isLoaded) ...[
491 + IconButton(
492 + icon: Icon(
493 + Icons.power_settings_new_outlined,
494 + size: 18,
495 + color: Colors.red[400],
496 + ),
497 + onPressed: onUnload,
498 + padding: EdgeInsets.zero,
499 + constraints: const BoxConstraints(),
500 + tooltip: 'Unload from server',
501 + ),
502 + const SizedBox(width: 8),
503 + ],
504 if (isSelected)
505 Icon(Icons.check_circle, color: accent, size: 22)
506 else