@tduncan / Cortana / commits / 98f0d7c

feat: integrate persona system prompts into conversation creation and refactor chat screen header UI

abdulmominsakib committed Apr 2, 2026 at 14:21 UTC 98f0d7c8819172ce0c539b6c754509046b04032f
6 files changed +546 -194
lib/core/providers/storage_providers.dart
+1 -1
@@ -27,7 +27,7 @@ final conversationsProvider = Provider<List<Conversation>>((ref) {
27
28 final recentConversationsProvider = Provider<List<Conversation>>((ref) {
29 final all = ref.watch(conversationsProvider);
30 - return all.take(5).toList();
30 + return all.take(3).toList();
31 });
32
33 final personasBoxProvider = Provider<Box<Persona>>((ref) {
lib/features/chat/data/chat_service.dart
+1 -3
@@ -359,9 +359,7 @@ class LMStudioChatService implements ChatService {
359 dynamic _formatInput(List<Message> messages) {
360 final formattedInputs = <Map<String, dynamic>>[];
361 for (final m in messages) {
362 - if (m.role == MessageRole.system) {
363 - formattedInputs.add({'type': 'system_prompt', 'content': m.content});
364 - } else {
362 + if (m.role != MessageRole.system) {
363 formattedInputs.add({'type': 'text', 'content': m.content});
364 }
365 }
lib/features/chat/providers/chat_providers.dart
+20 -8
@@ -13,6 +13,7 @@ import 'package:localmind/features/conversations/providers/conversation_provider
13 import 'package:localmind/features/models/data/models/model_info.dart';
14 import 'package:localmind/features/servers/providers/server_providers.dart';
15 import 'package:localmind/core/providers/app_providers.dart';
16 +import 'package:localmind/features/personas/providers/personas_providers.dart';
17
18 final selectedModelProvider =
19 NotifierProvider<SelectedModelNotifier, ModelInfo?>(() {
@@ -131,6 +132,7 @@ final chatParamsProvider = Provider<ChatParameters>((ref) {
132 int maxTokens = settings.maxTokens;
133 int contextLength = settings.contextLength;
134
135 + String? systemPrompt;
136 if (activeConv?.personaId != null) {
137 final personaId = activeConv!.personaId;
138 try {
@@ -139,14 +141,17 @@ final chatParamsProvider = Provider<ChatParameters>((ref) {
141 (p) => p.id == personaId,
142 orElse: () => null,
143 );
142 - if (persona != null && persona.preferredParams != null) {
143 - final params = persona.preferredParams as Map<String, dynamic>;
144 - if (params['temperature'] != null) {
145 - temperature = (params['temperature'] as num).toDouble();
146 - }
147 - if (params['topP'] != null) topP = (params['topP'] as num).toDouble();
148 - if (params['maxTokens'] != null) {
149 - maxTokens = (params['maxTokens'] as num).toInt();
144 + if (persona != null) {
145 + systemPrompt = persona.systemPrompt;
146 + if (persona.preferredParams != null) {
147 + final params = persona.preferredParams as Map<String, dynamic>;
148 + if (params['temperature'] != null) {
149 + temperature = (params['temperature'] as num).toDouble();
150 + }
151 + if (params['topP'] != null) topP = (params['topP'] as num).toDouble();
152 + if (params['maxTokens'] != null) {
153 + maxTokens = (params['maxTokens'] as num).toInt();
154 + }
155 }
156 }
157 } catch (_) {}
@@ -157,6 +162,7 @@ final chatParamsProvider = Provider<ChatParameters>((ref) {
162 topP: topP,
163 maxTokens: maxTokens,
164 contextLength: contextLength,
165 + systemPrompt: systemPrompt,
166 );
167 });
168
@@ -324,6 +330,7 @@ class ChatNotifier extends Notifier<ChatState> {
330 if (_currentConversationId == null) {
331 final server = ref.read(activeServerProvider);
332 final selectedModel = ref.read(selectedModelProvider);
333 + final selectedPersona = ref.read(selectedPersonaProvider);
334 final conversation = await ref
335 .read(conv.conversationsProvider.notifier)
336 .createConversation(
@@ -332,11 +339,16 @@ class ChatNotifier extends Notifier<ChatState> {
339 : content,
340 serverId: server?.id,
341 modelId: selectedModel?.id,
342 + personaId: selectedPersona?.id,
343 + systemPrompt: selectedPersona?.systemPrompt,
344 );
345 _currentConversationId = conversation.id;
346 ref
347 .read(conv.activeConversationProvider.notifier)
348 .setActiveConversation(conversation);
349 +
350 + // Clear preselected persona after it's applied to the new conversation
351 + ref.read(selectedPersonaProvider.notifier).clear();
352 }
353
354 final userMessage = Message(
lib/features/chat/views/chat_screen.dart
+467 -149
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:go_router/go_router.dart';
4 import 'package:shadcn_ui/shadcn_ui.dart';
5 +import 'package:hugeicons/hugeicons.dart';
6 +import 'package:localmind/features/servers/views/components/server_icon_picker.dart';
7 +import 'package:localmind/features/servers/data/models/server.dart';
8 import 'package:localmind/core/models/enums.dart';
9 import 'package:localmind/core/providers/app_providers.dart';
10 import 'package:localmind/core/providers/storage_providers.dart';
@@ -75,7 +78,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
78 final chatState = ref.watch(chatProvider);
79 final selectedModel = ref.watch(selectedModelProvider);
80 final connectionStatus = ref.watch(connectionStatusProvider);
78 - final isStreaming = ref.watch(isStreamingProvider);
81 + final activeServer = ref.watch(activeServerProvider);
82 final activeConversation = ref.watch(conv.activeConversationProvider);
83 final personaId = activeConversation?.personaId;
84 final persona = personaId != null
@@ -108,140 +111,146 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
111
112 return Column(
113 children: [
111 - AppBar(
112 - leading: ShadResponsiveBuilder(
113 - builder: (context, breakpoint) {
114 - final isDesktop =
115 - breakpoint >= ShadTheme.of(context).breakpoints.md;
116 - if (isDesktop) return const SizedBox.shrink();
117 - return IconButton(
118 - icon: const Icon(Icons.menu),
119 - onPressed: () => Scaffold.of(context).openDrawer(),
120 - );
121 - },
122 - ),
123 - title: GestureDetector(
124 - onTap: () => _showModelPicker(context),
125 - child: Row(
126 - mainAxisSize: MainAxisSize.min,
127 - children: [
128 - if (persona != null) ...[
129 - Text(persona.emoji, style: const TextStyle(fontSize: 18)),
130 - const SizedBox(width: 4),
131 - Text(
132 - persona.name,
133 - style: TextStyle(
134 - fontSize: 14,
135 - color: isDark
136 - ? const Color(0xFF9CA3AF)
137 - : const Color(0xFF6B7280),
114 + _ModelTopBar(
115 + selectedModel: selectedModel,
116 + onTap: () => _showModelPicker(context),
117 + ),
118 + Container(
119 + height: 56,
120 + padding: const EdgeInsets.symmetric(horizontal: 4),
121 + child: Row(
122 + children: [
123 + ShadResponsiveBuilder(
124 + builder: (context, breakpoint) {
125 + final isDesktop =
126 + breakpoint >= ShadTheme.of(context).breakpoints.md;
127 + if (isDesktop) return const SizedBox.shrink();
128 + return IconButton(
129 + icon: const Icon(Icons.menu),
130 + onPressed: () => Scaffold.of(context).openDrawer(),
131 + );
132 + },
133 + ),
134 + const SizedBox(width: 8),
135 + Expanded(
136 + child: Row(
137 + children: [
138 + if (activeServer != null) ...[
139 + _buildServerIcon(context, activeServer),
140 + const SizedBox(width: 8),
141 + ],
142 + Text(
143 + activeServer?.name ?? 'LocalMind',
144 + style: const TextStyle(
145 + fontSize: 18,
146 + fontWeight: FontWeight.bold,
147 + ),
148 ),
139 - ),
140 - const SizedBox(width: 8),
141 - ],
142 - if (isStreaming) ...[
143 - const SizedBox(
144 - width: 14,
145 - height: 14,
146 - child: CircularProgressIndicator(
147 - strokeWidth: 2,
148 - valueColor: AlwaysStoppedAnimation<Color>(
149 - Color(0xFF4CAF50),
149 + const SizedBox(width: 8),
150 + Container(
151 + width: 8,
152 + height: 8,
153 + decoration: BoxDecoration(
154 + shape: BoxShape.circle,
155 + color:
156 + connectionStatus == ConnectionStatus.connected
157 + ? Colors.green
158 + : Colors.grey,
159 ),
160 ),
152 - ),
153 - const SizedBox(width: 6),
154 - ],
155 - Text(
156 - selectedModel?.displayName ?? 'Select Model',
157 - style: const TextStyle(fontSize: 16),
158 - ),
159 - const SizedBox(width: 4),
160 - Icon(
161 - Icons.expand_more,
162 - size: 16,
163 - color: isDark
164 - ? const Color(0xFF888888)
165 - : const Color(0xFF666666),
166 - ),
167 - ],
168 - ),
169 - ),
170 - actions: [
171 - Consumer(
172 - builder: (context, ref, child) {
173 - final settings = ref.watch(settingsProvider);
174 - final mcpConfig = ref.watch(chatMcpConfigProvider);
175 - final isEnabled = settings.mcpEnabled && mcpConfig.enabled;
176 - return IconButton(
177 - icon: Icon(
178 - isEnabled ? Icons.extension : Icons.extension_off_outlined,
179 - color: isEnabled
180 - ? theme.colorScheme.primary
181 - : (isDark
182 - ? const Color(0xFF666666)
183 - : const Color(0xFF999999)),
184 - ),
185 - tooltip: isEnabled ? 'MCP Enabled' : 'MCP Disabled',
186 - onPressed: () => showMcpConfigSheet(context),
187 - );
188 - },
189 - ),
190 - IconButton(
191 - icon: const Icon(Icons.settings_outlined),
192 - onPressed: () => context.push(AppRoutes.settings),
193 - tooltip: 'Settings',
194 - ),
195 - PopupMenuButton<String>(
196 - icon: const Icon(Icons.more_vert),
197 - onSelected: (value) => _handleMenuAction(value, context),
198 - itemBuilder: (context) => [
199 - const PopupMenuItem(
200 - value: 'new_chat',
201 - child: ListTile(
202 - leading: Icon(Icons.add),
203 - title: Text('New Chat'),
204 - contentPadding: EdgeInsets.zero,
205 - ),
161 + ],
162 ),
207 - PopupMenuItem(
208 - value: 'persona',
209 - child: ListTile(
210 - leading: Icon(
211 - persona != null
212 - ? Icons.swap_horiz
213 - : Icons.smart_toy_outlined,
163 + ),
164 + Consumer(
165 + builder: (context, ref, child) {
166 + final settings = ref.watch(settingsProvider);
167 + final mcpConfig = ref.watch(chatMcpConfigProvider);
168 + final isEnabled = settings.mcpEnabled && mcpConfig.enabled;
169 + return IconButton(
170 + icon: Icon(
171 + isEnabled
172 + ? Icons.extension
173 + : Icons.extension_off_outlined,
174 + color: isEnabled
175 + ? theme.colorScheme.primary
176 + : (isDark
177 + ? const Color(0xFF666666)
178 + : const Color(0xFF999999)),
179 ),
215 - title: Text(
216 - persona != null ? 'Change Persona' : 'Set Persona',
180 + tooltip: isEnabled ? 'MCP Enabled' : 'MCP Disabled',
181 + onPressed: () => showMcpConfigSheet(context),
182 + );
183 + },
184 + ),
185 + IconButton(
186 + icon: const Icon(Icons.settings_outlined),
187 + onPressed: () => context.push(AppRoutes.settings),
188 + tooltip: 'Settings',
189 + ),
190 + PopupMenuButton<String>(
191 + icon: const Icon(Icons.more_vert),
192 + onSelected: (value) => _handleMenuAction(value, context),
193 + itemBuilder: (context) => [
194 + const PopupMenuItem(
195 + value: 'new_chat',
196 + child: ListTile(
197 + leading: Icon(Icons.add),
198 + title: Text('New Chat'),
199 + contentPadding: EdgeInsets.zero,
200 ),
218 - contentPadding: EdgeInsets.zero,
201 ),
220 - ),
221 - if (persona != null)
222 - const PopupMenuItem(
223 - value: 'remove_persona',
202 + PopupMenuItem(
203 + value: 'persona',
204 child: ListTile(
225 - leading: Icon(Icons.person_remove_outlined),
226 - title: Text('Remove Persona'),
205 + leading: Icon(
206 + persona != null
207 + ? Icons.swap_horiz
208 + : Icons.smart_toy_outlined,
209 + ),
210 + title: Text(
211 + persona != null ? 'Change Persona' : 'Set Persona',
212 + ),
213 contentPadding: EdgeInsets.zero,
214 ),
215 ),
230 - const PopupMenuItem(
231 - value: 'clear',
232 - child: ListTile(
233 - leading: Icon(Icons.delete_outline),
234 - title: Text('Clear Conversation'),
235 - contentPadding: EdgeInsets.zero,
216 + if (persona != null)
217 + const PopupMenuItem(
218 + value: 'remove_persona',
219 + child: ListTile(
220 + leading: Icon(Icons.person_remove_outlined),
221 + title: Text('Remove Persona'),
222 + contentPadding: EdgeInsets.zero,
223 + ),
224 + ),
225 + const PopupMenuItem(
226 + value: 'clear',
227 + child: ListTile(
228 + leading: Icon(Icons.delete_outline),
229 + title: Text('Clear Conversation'),
230 + contentPadding: EdgeInsets.zero,
231 + ),
232 ),
237 - ),
238 - ],
239 - ),
240 - ],
233 + ],
234 + ),
235 + ],
236 + ),
237 ),
238 if (connectionStatus == ConnectionStatus.disconnected ||
239 connectionStatus == ConnectionStatus.error)
240 _ConnectionBanner(status: connectionStatus),
241 + if (persona != null)
242 + _PersonaIndicator(
243 + persona: persona,
244 + onTap: () => _showPersonaPicker(context),
245 + onRemove: () {
246 + final activeConv = ref.read(conv.activeConversationProvider);
247 + if (activeConv != null) {
248 + ref
249 + .read(conv.conversationsProvider.notifier)
250 + .updatePersona(activeConv.id, null, null);
251 + }
252 + },
253 + ),
254 Expanded(
255 child: Stack(
256 children: [
@@ -258,6 +267,10 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
267 Scaffold.of(context).openDrawer();
268 }
269 },
270 + selectedModel: selectedModel,
271 + onModelTap: () => _showModelPicker(context),
272 + selectedPersona: ref.watch(selectedPersonaProvider),
273 + onPersonaTap: () => _showPersonaPickerForPreselection(context),
274 )
275 : _MessageList(
276 scrollController: _scrollController,
@@ -520,6 +533,161 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
533 },
534 );
535 }
536 +
537 + void _showPersonaPickerForPreselection(BuildContext context) {
538 + final personas = ref.read(personasNotifierProvider);
539 + final isDark = Theme.of(context).brightness == Brightness.dark;
540 + final currentPersona = ref.watch(selectedPersonaProvider);
541 +
542 + showModalBottomSheet(
543 + context: context,
544 + isScrollControlled: true,
545 + builder: (ctx) {
546 + return SafeArea(
547 + child: Container(
548 + padding: const EdgeInsets.symmetric(vertical: 16),
549 + child: Column(
550 + mainAxisSize: MainAxisSize.min,
551 + children: [
552 + Center(
553 + child: Container(
554 + width: 40,
555 + height: 4,
556 + decoration: BoxDecoration(
557 + color: isDark ? Colors.grey[600] : Colors.grey[300],
558 + borderRadius: BorderRadius.circular(2),
559 + ),
560 + ),
561 + ),
562 + const SizedBox(height: 16),
563 + Text(
564 + 'Select Persona',
565 + style: TextStyle(
566 + fontSize: 18,
567 + fontWeight: FontWeight.bold,
568 + color: isDark ? Colors.white : Colors.black,
569 + ),
570 + ),
571 + const SizedBox(height: 8),
572 + Flexible(
573 + child: ListView.builder(
574 + shrinkWrap: true,
575 + itemCount: personas.length,
576 + itemBuilder: (context, index) {
577 + final p = personas[index];
578 + final isSelected = p.id == currentPersona?.id;
579 + final accent = isDark
580 + ? const Color(0xFF3B82F6)
581 + : const Color(0xFF2563EB);
582 + return ListTile(
583 + leading: Text(
584 + p.emoji,
585 + style: const TextStyle(fontSize: 22),
586 + ),
587 + title: Text(
588 + p.name,
589 + style: TextStyle(
590 + fontWeight: isSelected
591 + ? FontWeight.w600
592 + : FontWeight.normal,
593 + color: isDark ? Colors.white : Colors.black,
594 + ),
595 + ),
596 + subtitle: p.description != null
597 + ? Text(
598 + p.description!,
599 + style: TextStyle(
600 + fontSize: 12,
601 + color: isDark
602 + ? const Color(0xFF888888)
603 + : const Color(0xFF999999),
604 + ),
605 + maxLines: 1,
606 + overflow: TextOverflow.ellipsis,
607 + )
608 + : null,
609 + trailing: isSelected
610 + ? Icon(Icons.check_circle, color: accent)
611 + : null,
612 + onTap: () {
613 + ref.read(selectedPersonaProvider.notifier).select(p);
614 + Navigator.pop(ctx);
615 + },
616 + );
617 + },
618 + ),
619 + ),
620 + ],
621 + ),
622 + ),
623 + );
624 + },
625 + );
626 + }
627 +
628 + Widget _buildServerIcon(BuildContext context, Server server) {
629 + final iconData =
630 + server.iconName != null
631 + ? getHugeIconByName(server.iconName)
632 + : getDefaultServerIcon(server.type.name);
633 +
634 + if (iconData == null) {
635 + return const Icon(Icons.dns, size: 18);
636 + }
637 +
638 + return HugeIcon(
639 + icon: iconData.icon,
640 + size: 18,
641 + color: Theme.of(context).colorScheme.primary,
642 + );
643 + }
644 +}
645 +
646 +class _ModelTopBar extends StatelessWidget {
647 + const _ModelTopBar({required this.selectedModel, required this.onTap});
648 +
649 + final dynamic selectedModel;
650 + final VoidCallback onTap;
651 +
652 + @override
653 + Widget build(BuildContext context) {
654 + final theme = Theme.of(context);
655 + final isDark = theme.brightness == Brightness.dark;
656 +
657 + return Container(
658 + width: double.infinity,
659 + color: theme.colorScheme.surface,
660 + child: SafeArea(
661 + bottom: false,
662 + child: GestureDetector(
663 + onTap: onTap,
664 + behavior: HitTestBehavior.opaque,
665 + child: Padding(
666 + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
667 + child: Row(
668 + mainAxisAlignment: MainAxisAlignment.center,
669 + children: [
670 + Text(
671 + selectedModel?.displayName ?? 'Select Model',
672 + style: TextStyle(
673 + fontSize: 13,
674 + fontWeight: FontWeight.w500,
675 + color: isDark ? Colors.white70 : Colors.black87,
676 + ),
677 + ),
678 + const SizedBox(width: 4),
679 + Icon(
680 + Icons.expand_more,
681 + size: 14,
682 + color: isDark ? Colors.white38 : Colors.black38,
683 + ),
684 + ],
685 + ),
686 + ),
687 + ),
688 + ),
689 + );
690 + }
691 }
692
693 class _ConnectionBanner extends StatelessWidget {
@@ -580,12 +748,20 @@ class _EmptyState extends StatefulWidget {
748 required this.quickPrompts,
749 required this.recentConversations,
750 required this.onSeeAll,
751 + required this.selectedModel,
752 + required this.onModelTap,
753 + this.selectedPersona,
754 + required this.onPersonaTap,
755 });
756
757 final void Function(String) onQuickPrompt;
758 final List<String> quickPrompts;
759 final List<Conversation> recentConversations;
760 final VoidCallback onSeeAll;
761 + final dynamic selectedModel;
762 + final VoidCallback onModelTap;
763 + final dynamic selectedPersona;
764 + final VoidCallback onPersonaTap;
765
766 @override
767 State<_EmptyState> createState() => _EmptyStateState();
@@ -668,37 +844,112 @@ class _EmptyStateState extends State<_EmptyState>
844 ),
845 );
846 },
671 - child: Text(
672 - 'Select Model LocalMind',
673 - style: TextStyle(
674 - fontSize: 28,
675 - fontWeight: FontWeight.bold,
676 - color: isDark ? Colors.white : Colors.black,
677 - ),
678 - ),
679 - ),
680 - const SizedBox(height: 8),
681 - TweenAnimationBuilder<double>(
682 - tween: Tween(begin: 0.0, end: 1.0),
683 - duration: const Duration(milliseconds: 350),
684 - curve: Curves.easeOut,
685 - builder: (context, value, child) {
686 - return Opacity(
687 - opacity: value,
688 - child: Transform.translate(
689 - offset: Offset(0, 15 * (1 - value)),
690 - child: child,
847 + child: Column(
848 + children: [
849 + GestureDetector(
850 + onTap: widget.onModelTap,
851 + child: Container(
852 + padding: const EdgeInsets.symmetric(
853 + horizontal: 16,
854 + vertical: 12,
855 + ),
856 + decoration: BoxDecoration(
857 + color: isDark ? const Color(0xFF1A1A1A) : Colors.white,
858 + borderRadius: BorderRadius.circular(12),
859 + border: Border.all(
860 + color: isDark
861 + ? const Color(0xFF333333)
862 + : const Color(0xFFE5E5E5),
863 + ),
864 + ),
865 + child: Row(
866 + mainAxisSize: MainAxisSize.min,
867 + children: [
868 + Icon(
869 + Icons.settings_suggest,
870 + size: 18,
871 + color: theme.colorScheme.primary,
872 + ),
873 + const SizedBox(width: 8),
874 + Text(
875 + widget.selectedModel?.displayName ?? 'Select Model',
876 + style: TextStyle(
877 + fontSize: 15,
878 + fontWeight: FontWeight.w600,
879 + color: isDark ? Colors.white : Colors.black,
880 + ),
881 + ),
882 + const SizedBox(width: 4),
883 + const Icon(
884 + Icons.expand_more,
885 + size: 16,
886 + color: Colors.grey,
887 + ),
888 + ],
889 + ),
890 + ),
891 ),
692 - );
693 - },
694 - child: Text(
695 - 'Your AI. Your Device. Your Rules.',
696 - style: TextStyle(
697 - fontSize: 14,
698 - color: isDark
699 - ? const Color(0xFF888888)
700 - : const Color(0xFF666666),
701 - ),
892 + const SizedBox(height: 12),
893 + GestureDetector(
894 + onTap: widget.onPersonaTap,
895 + child: Container(
896 + padding: const EdgeInsets.symmetric(
897 + horizontal: 16,
898 + vertical: 12,
899 + ),
900 + decoration: BoxDecoration(
901 + color: isDark ? const Color(0xFF1A1A1A) : Colors.white,
902 + borderRadius: BorderRadius.circular(12),
903 + border: Border.all(
904 + color: isDark
905 + ? const Color(0xFF333333)
906 + : const Color(0xFFE5E5E5),
907 + ),
908 + ),
909 + child: Row(
910 + mainAxisSize: MainAxisSize.min,
911 + children: [
912 + if (widget.selectedPersona != null) ...[
913 + Text(
914 + widget.selectedPersona!.emoji,
915 + style: const TextStyle(fontSize: 18),
916 + ),
917 + const SizedBox(width: 8),
918 + Text(
919 + widget.selectedPersona!.name,
920 + style: TextStyle(
921 + fontSize: 15,
922 + fontWeight: FontWeight.w600,
923 + color: isDark ? Colors.white : Colors.black,
924 + ),
925 + ),
926 + ] else ...[
927 + Icon(
928 + Icons.smart_toy_outlined,
929 + size: 18,
930 + color: theme.colorScheme.primary,
931 + ),
932 + const SizedBox(width: 8),
933 + Text(
934 + 'Select Persona',
935 + style: TextStyle(
936 + fontSize: 15,
937 + fontWeight: FontWeight.w600,
938 + color: isDark ? Colors.white : Colors.black,
939 + ),
940 + ),
941 + ],
942 + const SizedBox(width: 4),
943 + const Icon(
944 + Icons.expand_more,
945 + size: 16,
946 + color: Colors.grey,
947 + ),
948 + ],
949 + ),
950 + ),
951 + ),
952 + ],
953 ),
954 ),
955 const SizedBox(height: 32),
@@ -1120,3 +1371,70 @@ class _SmartReplyChipsState extends ConsumerState<_SmartReplyChips>
1371 );
1372 }
1373 }
1374 +class _PersonaIndicator extends StatelessWidget {
1375 + const _PersonaIndicator({
1376 + required this.persona,
1377 + required this.onTap,
1378 + required this.onRemove,
1379 + });
1380 +
1381 + final dynamic persona;
1382 + final VoidCallback onTap;
1383 + final VoidCallback onRemove;
1384 +
1385 + @override
1386 + Widget build(BuildContext context) {
1387 + final theme = Theme.of(context);
1388 + final isDark = theme.brightness == Brightness.dark;
1389 +
1390 + return Container(
1391 + width: double.infinity,
1392 + color: isDark ? const Color(0xFF121212) : const Color(0xFFF9F9F9),
1393 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
1394 + child: Row(
1395 + children: [
1396 + GestureDetector(
1397 + onTap: onTap,
1398 + child: Container(
1399 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
1400 + decoration: BoxDecoration(
1401 + color: theme.colorScheme.primary.withValues(alpha: 0.1),
1402 + borderRadius: BorderRadius.circular(16),
1403 + border: Border.all(
1404 + color: theme.colorScheme.primary.withValues(alpha: 0.2),
1405 + ),
1406 + ),
1407 + child: Row(
1408 + mainAxisSize: MainAxisSize.min,
1409 + children: [
1410 + Text(
1411 + persona.emoji,
1412 + style: const TextStyle(fontSize: 16),
1413 + ),
1414 + const SizedBox(width: 6),
1415 + Text(
1416 + persona.name,
1417 + style: TextStyle(
1418 + fontSize: 13,
1419 + fontWeight: FontWeight.w600,
1420 + color: theme.colorScheme.primary,
1421 + ),
1422 + ),
1423 + ],
1424 + ),
1425 + ),
1426 + ),
1427 + const Spacer(),
1428 + IconButton(
1429 + icon: const Icon(Icons.close, size: 16),
1430 + onPressed: onRemove,
1431 + padding: EdgeInsets.zero,
1432 + constraints: const BoxConstraints(),
1433 + tooltip: 'Remove Persona',
1434 + color: isDark ? Colors.white38 : Colors.black38,
1435 + ),
1436 + ],
1437 + ),
1438 + );
1439 + }
1440 +}
lib/features/chat/views/components/chat_input_bar.dart
+39 -33
@@ -330,40 +330,46 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar>
330 ),
331 ),
332 ),
333 - AnimatedSize(
334 - duration: const Duration(milliseconds: 150),
335 - curve: Curves.easeOut,
336 - child: AnimatedOpacity(
337 - opacity: _isComposing ? 1.0 : 0.0,
338 - duration: const Duration(milliseconds: 150),
339 - child: _isComposing
340 - ? Padding(
341 - padding: const EdgeInsets.only(
342 - right: 12,
343 - bottom: 12,
344 - ),
345 - child: TweenAnimationBuilder<double>(
346 - tween: Tween(begin: 0.5, end: 1.0),
347 - duration: const Duration(milliseconds: 150),
348 - builder: (context, scale, child) {
349 - return Transform.scale(
350 - scale: scale,
351 - child: child,
352 - );
353 - },
354 - child: Text(
355 - '${_controller.text.length}',
356 - style: TextStyle(
357 - fontSize: 11,
358 - color: isDark
359 - ? const Color(0xFF666666)
360 - : const Color(0xFF999999),
333 + ValueListenableBuilder<TextEditingValue>(
334 + valueListenable: _controller,
335 + builder: (context, value, child) {
336 + final showCounter = _isComposing && value.text.length >= 300;
337 + return AnimatedSize(
338 + duration: const Duration(milliseconds: 150),
339 + curve: Curves.easeOut,
340 + child: AnimatedOpacity(
341 + opacity: showCounter ? 1.0 : 0.0,
342 + duration: const Duration(milliseconds: 150),
343 + child: showCounter
344 + ? Padding(
345 + padding: const EdgeInsets.only(
346 + right: 12,
347 + bottom: 12,
348 ),
362 - ),
363 - ),
364 - )
365 - : const SizedBox(width: 0),
366 - ),
349 + child: TweenAnimationBuilder<double>(
350 + tween: Tween(begin: 0.5, end: 1.0),
351 + duration: const Duration(milliseconds: 150),
352 + builder: (context, scale, child) {
353 + return Transform.scale(
354 + scale: scale,
355 + child: child,
356 + );
357 + },
358 + child: Text(
359 + '${value.text.length}',
360 + style: TextStyle(
361 + fontSize: 11,
362 + color: isDark
363 + ? const Color(0xFF666666)
364 + : const Color(0xFF999999),
365 + ),
366 + ),
367 + ),
368 + )
369 + : const SizedBox(width: 0),
370 + ),
371 + );
372 + },
373 ),
374 ],
375 ),
lib/features/personas/providers/personas_providers.dart
+18
@@ -8,6 +8,24 @@ final personaSearchQueryProvider =
8 _PersonaSearchNotifier.new,
9 );
10
11 +final selectedPersonaProvider =
12 + NotifierProvider<SelectedPersonaNotifier, Persona?>(
13 + SelectedPersonaNotifier.new,
14 + );
15 +
16 +class SelectedPersonaNotifier extends Notifier<Persona?> {
17 + @override
18 + Persona? build() => null;
19 +
20 + void select(Persona? persona) {
21 + state = persona;
22 + }
23 +
24 + void clear() {
25 + state = null;
26 + }
27 +}
28 +
29 class _PersonaSearchNotifier extends Notifier<String> {
30 @override
31 String build() => '';