refactor: enhance conversation fetching and folder management
- Improved the logic for fetching conversations within folders by introducing a new condition to determine when to fetch folder conversations. - Added detailed logging for successful and failed fetch attempts to aid in debugging and monitoring. - Implemented a method to resolve folder conversations, ensuring that conversations are displayed in the correct order and that placeholders are used when necessary. - Updated the ChatsDrawer to utilize the new conversation resolution logic, enhancing the user experience by ensuring all relevant conversations are displayed. - This refactor streamlines conversation management and improves the overall efficiency of the chat interface.
This commit is contained in:
@@ -814,20 +814,37 @@ Future<List<Conversation>> conversations(Ref ref) async {
|
|||||||
final missingIds = folder.conversationIds
|
final missingIds = folder.conversationIds
|
||||||
.where((id) => !existingIds.contains(id))
|
.where((id) => !existingIds.contains(id))
|
||||||
.toList();
|
.toList();
|
||||||
if (missingIds.isEmpty) continue;
|
|
||||||
|
final hasKnownConversations = conversationMap.values.any(
|
||||||
|
(conversation) => conversation.folderId == folder.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
final shouldFetchFolder =
|
||||||
|
apiSvc != null &&
|
||||||
|
(missingIds.isNotEmpty ||
|
||||||
|
(!hasKnownConversations && folder.conversationIds.isEmpty));
|
||||||
|
|
||||||
List<Conversation> folderConvs = const [];
|
List<Conversation> folderConvs = const [];
|
||||||
try {
|
if (shouldFetchFolder) {
|
||||||
if (apiSvc != null) {
|
try {
|
||||||
folderConvs = await apiSvc.getConversationsInFolder(folder.id);
|
folderConvs = await apiSvc.getConversationsInFolder(folder.id);
|
||||||
|
DebugLogger.log(
|
||||||
|
'folder-sync',
|
||||||
|
scope: 'conversations/map',
|
||||||
|
data: {
|
||||||
|
'folderId': folder.id,
|
||||||
|
'fetched': folderConvs.length,
|
||||||
|
'missingIds': missingIds.length,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger.error(
|
||||||
|
'folder-fetch-failed',
|
||||||
|
scope: 'conversations/map',
|
||||||
|
error: e,
|
||||||
|
data: {'folderId': folder.id},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
DebugLogger.error(
|
|
||||||
'folder-fetch-failed',
|
|
||||||
scope: 'conversations/map',
|
|
||||||
error: e,
|
|
||||||
data: {'folderId': folder.id},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index fetched folder conversations for quick lookup
|
// Index fetched folder conversations for quick lookup
|
||||||
@@ -867,6 +884,21 @@ Future<List<Conversation>> conversations(Ref ref) async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (folderConvs.isNotEmpty && folder.conversationIds.isEmpty) {
|
||||||
|
for (final conv in folderConvs) {
|
||||||
|
final toAdd = conv.folderId == null
|
||||||
|
? conv.copyWith(folderId: folder.id)
|
||||||
|
: conv;
|
||||||
|
conversationMap[toAdd.id] = toAdd;
|
||||||
|
existingIds.add(toAdd.id);
|
||||||
|
DebugLogger.log(
|
||||||
|
'add-folder-fetch',
|
||||||
|
scope: 'conversations/map',
|
||||||
|
data: {'conversationId': toAdd.id, 'folderId': folder.id},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert map back to list - this ensures no duplicates by ID
|
// Convert map back to list - this ensures no duplicates by ID
|
||||||
|
|||||||
@@ -740,10 +740,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: Padding(
|
child: SafeArea(
|
||||||
padding: EdgeInsets.only(
|
top: false,
|
||||||
bottom: MediaQuery.of(context).viewPadding.bottom,
|
bottom: true,
|
||||||
),
|
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxHeight: MediaQuery.of(context).size.height * 0.4,
|
maxHeight: MediaQuery.of(context).size.height * 0.4,
|
||||||
@@ -1001,9 +1000,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
Spacing.inputPadding - Spacing.xs,
|
Spacing.inputPadding,
|
||||||
0,
|
0,
|
||||||
Spacing.lg + Spacing.xs,
|
Spacing.inputPadding,
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -1113,7 +1112,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
activeColor = null;
|
activeColor = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const double iconSize = IconSize.large;
|
const double iconSize = IconSize.xl;
|
||||||
|
|
||||||
final Color iconColor = !enabled
|
final Color iconColor = !enabled
|
||||||
? context.conduitTheme.textPrimary.withValues(alpha: Alpha.disabled)
|
? context.conduitTheme.textPrimary.withValues(alpha: Alpha.disabled)
|
||||||
@@ -1124,24 +1123,14 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
message: tooltip,
|
message: tooltip,
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: enabled ? 1.0 : Alpha.disabled,
|
opacity: enabled ? 1.0 : Alpha.disabled,
|
||||||
child: SizedBox(
|
child: IconButton(
|
||||||
width: TouchTarget.minimum,
|
onPressed: enabled
|
||||||
height: TouchTarget.minimum,
|
? () {
|
||||||
child: Material(
|
HapticFeedback.selectionClick();
|
||||||
color: Colors.transparent,
|
_showOverflowSheet();
|
||||||
child: InkWell(
|
}
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
: null,
|
||||||
onTap: enabled
|
icon: Icon(icon, size: iconSize, color: iconColor),
|
||||||
? () {
|
|
||||||
HapticFeedback.selectionClick();
|
|
||||||
_showOverflowSheet();
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
child: Center(
|
|
||||||
child: Icon(icon, size: iconSize, color: iconColor),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1280,54 +1269,25 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
message: AppLocalizations.of(context)!.voiceInput,
|
message: AppLocalizations.of(context)!.voiceInput,
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: enabledMic ? Alpha.primary : Alpha.disabled,
|
opacity: enabledMic ? Alpha.primary : Alpha.disabled,
|
||||||
child: IgnorePointer(
|
child: IconButton(
|
||||||
ignoring: !enabledMic,
|
onPressed: enabledMic
|
||||||
child: Material(
|
? () {
|
||||||
color: Colors.transparent,
|
HapticFeedback.selectionClick();
|
||||||
shape: RoundedRectangleBorder(
|
_toggleVoice();
|
||||||
borderRadius: BorderRadius.circular(radius),
|
}
|
||||||
side: BorderSide(
|
: null,
|
||||||
color: _isRecording
|
icon: Icon(
|
||||||
? context.conduitTheme.buttonPrimary
|
Platform.isIOS ? CupertinoIcons.mic : Icons.mic,
|
||||||
: context.conduitTheme.cardBorder.withValues(
|
size: IconSize.large,
|
||||||
alpha: enabledMic ? Alpha.strong : Alpha.medium,
|
color: _isRecording
|
||||||
),
|
? context.conduitTheme.buttonPrimary
|
||||||
width: BorderWidth.regular,
|
: (enabledMic
|
||||||
),
|
? context.conduitTheme.textPrimary.withValues(
|
||||||
),
|
alpha: Alpha.strong,
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(radius),
|
|
||||||
onTap: enabledMic
|
|
||||||
? () {
|
|
||||||
HapticFeedback.selectionClick();
|
|
||||||
_toggleVoice();
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
child: Container(
|
|
||||||
width: buttonSize,
|
|
||||||
height: buttonSize,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _isRecording
|
|
||||||
? context.conduitTheme.buttonPrimary.withValues(
|
|
||||||
alpha: Alpha.buttonPressed,
|
|
||||||
)
|
)
|
||||||
: context.conduitTheme.cardBackground,
|
: context.conduitTheme.textPrimary.withValues(
|
||||||
borderRadius: BorderRadius.circular(radius),
|
alpha: Alpha.disabled,
|
||||||
boxShadow: ConduitShadows.button,
|
)),
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.mic_fill : Icons.mic,
|
|
||||||
size: IconSize.medium,
|
|
||||||
color: _isRecording
|
|
||||||
? context.conduitTheme.buttonPrimary
|
|
||||||
: (enabledMic
|
|
||||||
? context.conduitTheme.textPrimary
|
|
||||||
: context.conduitTheme.textPrimary.withValues(
|
|
||||||
alpha: Alpha.disabled,
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import '../../../shared/utils/conversation_context_menu.dart';
|
|||||||
import '../../../shared/widgets/user_avatar.dart';
|
import '../../../shared/widgets/user_avatar.dart';
|
||||||
import '../../../shared/widgets/model_avatar.dart';
|
import '../../../shared/widgets/model_avatar.dart';
|
||||||
import '../../../core/models/model.dart';
|
import '../../../core/models/model.dart';
|
||||||
|
import '../../../core/models/conversation.dart';
|
||||||
|
import '../../../core/models/folder.dart';
|
||||||
|
|
||||||
class ChatsDrawer extends ConsumerStatefulWidget {
|
class ChatsDrawer extends ConsumerStatefulWidget {
|
||||||
const ChatsDrawer({super.key});
|
const ChatsDrawer({super.key});
|
||||||
@@ -322,11 +324,18 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
grouped.putIfAbsent(id, () => []).add(c);
|
grouped.putIfAbsent(id, () => []).add(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final expandedMap = ref.watch(_expandedFoldersProvider);
|
||||||
|
|
||||||
// Show all folders (including empty)
|
// Show all folders (including empty)
|
||||||
final sections = folders.map((folder) {
|
final sections = folders.map((folder) {
|
||||||
final expandedMap = ref.watch(_expandedFoldersProvider);
|
final existing = grouped[folder.id] ?? const <dynamic>[];
|
||||||
final isExpanded = expandedMap[folder.id] ?? false;
|
final convs = _resolveFolderConversations(
|
||||||
final convs = grouped[folder.id] ?? const <dynamic>[];
|
folder,
|
||||||
|
existing,
|
||||||
|
);
|
||||||
|
final isExpanded =
|
||||||
|
expandedMap[folder.id] ?? folder.isExpanded;
|
||||||
|
final hasItems = convs.isNotEmpty;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -334,8 +343,9 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
folder.id,
|
folder.id,
|
||||||
folder.name,
|
folder.name,
|
||||||
convs.length,
|
convs.length,
|
||||||
|
defaultExpanded: folder.isExpanded,
|
||||||
),
|
),
|
||||||
if (isExpanded && convs.isNotEmpty) ...[
|
if (isExpanded && hasItems) ...[
|
||||||
const SizedBox(height: Spacing.xs),
|
const SizedBox(height: Spacing.xs),
|
||||||
...convs.map(
|
...convs.map(
|
||||||
(c) => _buildTileFor(c, inFolder: true),
|
(c) => _buildTileFor(c, inFolder: true),
|
||||||
@@ -480,10 +490,14 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
grouped.putIfAbsent(id, () => []).add(c);
|
grouped.putIfAbsent(id, () => []).add(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final expandedMap = ref.watch(_expandedFoldersProvider);
|
||||||
|
|
||||||
final sections = folders.map((folder) {
|
final sections = folders.map((folder) {
|
||||||
final expandedMap = ref.watch(_expandedFoldersProvider);
|
final existing = grouped[folder.id] ?? const <dynamic>[];
|
||||||
final isExpanded = expandedMap[folder.id] ?? false;
|
final convs = _resolveFolderConversations(folder, existing);
|
||||||
final convs = grouped[folder.id] ?? const <dynamic>[];
|
final isExpanded =
|
||||||
|
expandedMap[folder.id] ?? folder.isExpanded;
|
||||||
|
final hasItems = convs.isNotEmpty;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -491,8 +505,9 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
folder.id,
|
folder.id,
|
||||||
folder.name,
|
folder.name,
|
||||||
convs.length,
|
convs.length,
|
||||||
|
defaultExpanded: folder.isExpanded,
|
||||||
),
|
),
|
||||||
if (isExpanded && convs.isNotEmpty) ...[
|
if (isExpanded && hasItems) ...[
|
||||||
const SizedBox(height: Spacing.xs),
|
const SizedBox(height: Spacing.xs),
|
||||||
...convs.map((c) => _buildTileFor(c, inFolder: true)),
|
...convs.map((c) => _buildTileFor(c, inFolder: true)),
|
||||||
const SizedBox(height: Spacing.sm),
|
const SizedBox(height: Spacing.sm),
|
||||||
@@ -627,10 +642,15 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFolderHeader(String folderId, String name, int count) {
|
Widget _buildFolderHeader(
|
||||||
|
String folderId,
|
||||||
|
String name,
|
||||||
|
int count, {
|
||||||
|
bool defaultExpanded = false,
|
||||||
|
}) {
|
||||||
final theme = context.conduitTheme;
|
final theme = context.conduitTheme;
|
||||||
final expandedMap = ref.watch(_expandedFoldersProvider);
|
final expandedMap = ref.watch(_expandedFoldersProvider);
|
||||||
final isExpanded = expandedMap[folderId] ?? false;
|
final isExpanded = expandedMap[folderId] ?? defaultExpanded;
|
||||||
final isHover = _dragHoverFolderId == folderId;
|
final isHover = _dragHoverFolderId == folderId;
|
||||||
return DragTarget<_DragConversationData>(
|
return DragTarget<_DragConversationData>(
|
||||||
onWillAcceptWithDetails: (details) {
|
onWillAcceptWithDetails: (details) {
|
||||||
@@ -696,7 +716,8 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
borderRadius: BorderRadius.zero,
|
borderRadius: BorderRadius.zero,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final current = {...ref.read(_expandedFoldersProvider)};
|
final current = {...ref.read(_expandedFoldersProvider)};
|
||||||
current[folderId] = !isExpanded;
|
final next = !isExpanded;
|
||||||
|
current[folderId] = next;
|
||||||
ref.read(_expandedFoldersProvider.notifier).set(current);
|
ref.read(_expandedFoldersProvider.notifier).set(current);
|
||||||
},
|
},
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
@@ -780,6 +801,73 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<dynamic> _resolveFolderConversations(
|
||||||
|
Folder folder,
|
||||||
|
List<dynamic> existing,
|
||||||
|
) {
|
||||||
|
// Preserve the current conversational ordering while ensuring items from
|
||||||
|
// the folder metadata appear even if the main list has not fetched them
|
||||||
|
// yet. This primarily happens when chats live exclusively inside folders
|
||||||
|
// and the conversations endpoint omits them.
|
||||||
|
final result = <dynamic>[];
|
||||||
|
|
||||||
|
final existingMap = <String, dynamic>{};
|
||||||
|
for (final item in existing) {
|
||||||
|
final id = _conversationId(item);
|
||||||
|
if (id != null) {
|
||||||
|
existingMap[id] = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folder.conversationIds.isNotEmpty) {
|
||||||
|
for (final convId in folder.conversationIds) {
|
||||||
|
final existingItem = existingMap.remove(convId);
|
||||||
|
if (existingItem != null) {
|
||||||
|
result.add(existingItem);
|
||||||
|
} else {
|
||||||
|
result.add(_placeholderConversation(convId, folder.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append any remaining conversations that claim this folder but are
|
||||||
|
// missing from the folder metadata list (defensive for API drift).
|
||||||
|
result.addAll(existingMap.values);
|
||||||
|
} else {
|
||||||
|
result.addAll(existingMap.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Conversation _placeholderConversation(
|
||||||
|
String conversationId,
|
||||||
|
String folderId,
|
||||||
|
) {
|
||||||
|
const fallbackTitle = 'Chat';
|
||||||
|
final epoch = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
return Conversation(
|
||||||
|
id: conversationId,
|
||||||
|
title: fallbackTitle,
|
||||||
|
createdAt: epoch,
|
||||||
|
updatedAt: epoch,
|
||||||
|
folderId: folderId,
|
||||||
|
messages: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _conversationId(dynamic item) {
|
||||||
|
if (item is Conversation) return item.id;
|
||||||
|
try {
|
||||||
|
final value = (item as dynamic).id;
|
||||||
|
if (value is String) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
void _showFolderContextMenu(
|
void _showFolderContextMenu(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String folderId,
|
String folderId,
|
||||||
|
|||||||
Reference in New Issue
Block a user