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
|
||||
.where((id) => !existingIds.contains(id))
|
||||
.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 [];
|
||||
try {
|
||||
if (apiSvc != null) {
|
||||
if (shouldFetchFolder) {
|
||||
try {
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -740,10 +740,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
],
|
||||
),
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewPadding.bottom,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: true,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.4,
|
||||
@@ -1001,9 +1000,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Spacing.inputPadding - Spacing.xs,
|
||||
Spacing.inputPadding,
|
||||
0,
|
||||
Spacing.lg + Spacing.xs,
|
||||
Spacing.inputPadding,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
@@ -1113,7 +1112,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
activeColor = null;
|
||||
}
|
||||
|
||||
const double iconSize = IconSize.large;
|
||||
const double iconSize = IconSize.xl;
|
||||
|
||||
final Color iconColor = !enabled
|
||||
? context.conduitTheme.textPrimary.withValues(alpha: Alpha.disabled)
|
||||
@@ -1124,24 +1123,14 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
message: tooltip,
|
||||
child: Opacity(
|
||||
opacity: enabled ? 1.0 : Alpha.disabled,
|
||||
child: SizedBox(
|
||||
width: TouchTarget.minimum,
|
||||
height: TouchTarget.minimum,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
||||
onTap: enabled
|
||||
? () {
|
||||
HapticFeedback.selectionClick();
|
||||
_showOverflowSheet();
|
||||
}
|
||||
: null,
|
||||
child: Center(
|
||||
child: Icon(icon, size: iconSize, color: iconColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: enabled
|
||||
? () {
|
||||
HapticFeedback.selectionClick();
|
||||
_showOverflowSheet();
|
||||
}
|
||||
: null,
|
||||
icon: Icon(icon, size: iconSize, color: iconColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1280,54 +1269,25 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
message: AppLocalizations.of(context)!.voiceInput,
|
||||
child: Opacity(
|
||||
opacity: enabledMic ? Alpha.primary : Alpha.disabled,
|
||||
child: IgnorePointer(
|
||||
ignoring: !enabledMic,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
side: BorderSide(
|
||||
color: _isRecording
|
||||
? context.conduitTheme.buttonPrimary
|
||||
: context.conduitTheme.cardBorder.withValues(
|
||||
alpha: enabledMic ? Alpha.strong : Alpha.medium,
|
||||
),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
),
|
||||
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,
|
||||
child: IconButton(
|
||||
onPressed: enabledMic
|
||||
? () {
|
||||
HapticFeedback.selectionClick();
|
||||
_toggleVoice();
|
||||
}
|
||||
: null,
|
||||
icon: Icon(
|
||||
Platform.isIOS ? CupertinoIcons.mic : Icons.mic,
|
||||
size: IconSize.large,
|
||||
color: _isRecording
|
||||
? context.conduitTheme.buttonPrimary
|
||||
: (enabledMic
|
||||
? context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.strong,
|
||||
)
|
||||
: context.conduitTheme.cardBackground,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
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,
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
: 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/model_avatar.dart';
|
||||
import '../../../core/models/model.dart';
|
||||
import '../../../core/models/conversation.dart';
|
||||
import '../../../core/models/folder.dart';
|
||||
|
||||
class ChatsDrawer extends ConsumerStatefulWidget {
|
||||
const ChatsDrawer({super.key});
|
||||
@@ -322,11 +324,18 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
||||
grouped.putIfAbsent(id, () => []).add(c);
|
||||
}
|
||||
|
||||
final expandedMap = ref.watch(_expandedFoldersProvider);
|
||||
|
||||
// Show all folders (including empty)
|
||||
final sections = folders.map((folder) {
|
||||
final expandedMap = ref.watch(_expandedFoldersProvider);
|
||||
final isExpanded = expandedMap[folder.id] ?? false;
|
||||
final convs = grouped[folder.id] ?? const <dynamic>[];
|
||||
final existing = grouped[folder.id] ?? const <dynamic>[];
|
||||
final convs = _resolveFolderConversations(
|
||||
folder,
|
||||
existing,
|
||||
);
|
||||
final isExpanded =
|
||||
expandedMap[folder.id] ?? folder.isExpanded;
|
||||
final hasItems = convs.isNotEmpty;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -334,8 +343,9 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
||||
folder.id,
|
||||
folder.name,
|
||||
convs.length,
|
||||
defaultExpanded: folder.isExpanded,
|
||||
),
|
||||
if (isExpanded && convs.isNotEmpty) ...[
|
||||
if (isExpanded && hasItems) ...[
|
||||
const SizedBox(height: Spacing.xs),
|
||||
...convs.map(
|
||||
(c) => _buildTileFor(c, inFolder: true),
|
||||
@@ -480,10 +490,14 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
||||
grouped.putIfAbsent(id, () => []).add(c);
|
||||
}
|
||||
|
||||
final expandedMap = ref.watch(_expandedFoldersProvider);
|
||||
|
||||
final sections = folders.map((folder) {
|
||||
final expandedMap = ref.watch(_expandedFoldersProvider);
|
||||
final isExpanded = expandedMap[folder.id] ?? false;
|
||||
final convs = grouped[folder.id] ?? const <dynamic>[];
|
||||
final existing = grouped[folder.id] ?? const <dynamic>[];
|
||||
final convs = _resolveFolderConversations(folder, existing);
|
||||
final isExpanded =
|
||||
expandedMap[folder.id] ?? folder.isExpanded;
|
||||
final hasItems = convs.isNotEmpty;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -491,8 +505,9 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
||||
folder.id,
|
||||
folder.name,
|
||||
convs.length,
|
||||
defaultExpanded: folder.isExpanded,
|
||||
),
|
||||
if (isExpanded && convs.isNotEmpty) ...[
|
||||
if (isExpanded && hasItems) ...[
|
||||
const SizedBox(height: Spacing.xs),
|
||||
...convs.map((c) => _buildTileFor(c, inFolder: true)),
|
||||
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 expandedMap = ref.watch(_expandedFoldersProvider);
|
||||
final isExpanded = expandedMap[folderId] ?? false;
|
||||
final isExpanded = expandedMap[folderId] ?? defaultExpanded;
|
||||
final isHover = _dragHoverFolderId == folderId;
|
||||
return DragTarget<_DragConversationData>(
|
||||
onWillAcceptWithDetails: (details) {
|
||||
@@ -696,7 +716,8 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
||||
borderRadius: BorderRadius.zero,
|
||||
onTap: () {
|
||||
final current = {...ref.read(_expandedFoldersProvider)};
|
||||
current[folderId] = !isExpanded;
|
||||
final next = !isExpanded;
|
||||
current[folderId] = next;
|
||||
ref.read(_expandedFoldersProvider.notifier).set(current);
|
||||
},
|
||||
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(
|
||||
BuildContext context,
|
||||
String folderId,
|
||||
|
||||
Reference in New Issue
Block a user