feat: enhanced sockets, tuned retries and polling fallback
This commit is contained in:
@@ -55,10 +55,27 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
|
||||
// locally streamed assistant content with an outdated server copy.
|
||||
if (previous?.updatedAt != next?.updatedAt) {
|
||||
final serverMessages = next?.messages ?? const [];
|
||||
// Only replace local messages if the server has strictly more messages
|
||||
// (i.e., includes new content we don't have yet).
|
||||
// Primary rule: adopt server messages when there are strictly more of them.
|
||||
if (serverMessages.length > state.length) {
|
||||
state = serverMessages;
|
||||
return;
|
||||
}
|
||||
|
||||
// Secondary rule: if counts are equal but the last assistant message grew,
|
||||
// adopt the server copy to recover from missed socket events.
|
||||
if (serverMessages.isNotEmpty && state.isNotEmpty) {
|
||||
final serverLast = serverMessages.last;
|
||||
final localLast = state.last;
|
||||
final serverText = serverLast.content.trim();
|
||||
final localText = localLast.content.trim();
|
||||
final sameLastId = serverLast.id == localLast.id;
|
||||
final isAssistant = serverLast.role == 'assistant';
|
||||
final serverHasMore = serverText.isNotEmpty && serverText.length > localText.length;
|
||||
final localEmptyButServerHas = localText.isEmpty && serverText.isNotEmpty;
|
||||
if (sameLastId && isAssistant && (serverHasMore || localEmptyButServerHas)) {
|
||||
state = serverMessages;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1408,16 +1408,21 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
try {
|
||||
final full = await api.getConversation(active.id);
|
||||
ref
|
||||
.read(activeConversationProvider.notifier)
|
||||
.state =
|
||||
full;
|
||||
.read(activeConversationProvider.notifier)
|
||||
.state = full;
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'DEBUG: Failed to refresh conversation: $e',
|
||||
);
|
||||
// Could show a snackbar here if needed
|
||||
debugPrint('DEBUG: Failed to refresh conversation: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Also refresh the conversations list to reconcile missed events
|
||||
// and keep timestamps/order in sync with the server.
|
||||
try {
|
||||
ref.invalidate(conversationsProvider);
|
||||
// Best-effort await to stabilize UI; ignore errors.
|
||||
await ref.read(conversationsProvider.future);
|
||||
} catch (_) {}
|
||||
|
||||
// Add small delay for better UX feedback
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Padding(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(Spacing.pagePadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -102,6 +102,110 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.lg),
|
||||
Text(
|
||||
'Realtime',
|
||||
style: context.conduitTheme.headingSmall?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
ConduitCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.listItemPadding,
|
||||
vertical: Spacing.sm,
|
||||
),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(Spacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.buttonPrimary
|
||||
.withValues(alpha: Alpha.highlight),
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppBorderRadius.small),
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.waveform
|
||||
: Icons.sync_alt,
|
||||
color: context.conduitTheme.buttonPrimary,
|
||||
size: IconSize.medium,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
'Transport mode',
|
||||
style: context.conduitTheme.bodyLarge?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Choose how the app connects for realtime updates.',
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Spacing.listItemPadding,
|
||||
0,
|
||||
Spacing.listItemPadding,
|
||||
Spacing.md,
|
||||
),
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: settings.socketTransportMode,
|
||||
onChanged: (v) async {
|
||||
if (v == null) return;
|
||||
await ref
|
||||
.read(appSettingsProvider.notifier)
|
||||
.setSocketTransportMode(v);
|
||||
},
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'auto',
|
||||
child: Text('Auto (Polling + WebSocket)'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'ws',
|
||||
child: Text('WebSocket only'),
|
||||
),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Mode',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Spacing.listItemPadding,
|
||||
0,
|
||||
Spacing.listItemPadding,
|
||||
Spacing.md,
|
||||
),
|
||||
child: Text(
|
||||
settings.socketTransportMode == 'auto'
|
||||
? 'More robust on restrictive networks. Upgrades to WebSocket when possible.'
|
||||
: 'Lower overhead, but may fail behind strict proxies/firewalls.',
|
||||
style: context.conduitTheme.caption?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user