From 73bb426bf062b9ded860181809aefbd8863bbb10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A1imon=20Kala?= Date: Sun, 30 Aug 2026 16:33:29 +0200 Subject: [PATCH 1/2] feat(message): add thinking blocks with live streaming Render reasoning parts as collapsible thinking blocks and apply message.part.delta events to reasoning parts (deltas use field "text" regardless of target part type). Streaming behavior is controlled by an auto-expand toggle in settings. --- lib/l10n/app_en.arb | 10 + lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_en.dart | 11 + lib/l10n/app_localizations_zh.dart | 11 + lib/l10n/app_zh.arb | 10 + lib/pages/settings_page.dart | 65 +++++ lib/providers/session_provider.dart | 37 ++- .../thinking_auto_expand_provider.dart | 48 ++++ lib/widgets/message/message_bubble.dart | 82 ++++-- lib/widgets/message/message_list.dart | 23 ++ lib/widgets/message/message_part.dart | 262 ++++++++++++++++-- test/session_provider_test.dart | 157 +++++++++++ test/thinking_auto_expand_provider_test.dart | 68 +++++ test/thinking_block_widget_test.dart | 149 ++++++++++ 14 files changed, 898 insertions(+), 53 deletions(-) create mode 100644 lib/providers/thinking_auto_expand_provider.dart create mode 100644 test/thinking_auto_expand_provider_test.dart create mode 100644 test/thinking_block_widget_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 91022b1..8ad46c1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -25,6 +25,16 @@ "sessionCompletionNotificationModeNone": "Never", "sessionCompletionNotificationModeBackgroundOnly": "Only when app is in background", "sessionCompletionNotificationModeAlways": "Also when app is in foreground", + "settingsThinking": "Auto-expand thinking", + "thinkingInProgress": "Thinking…", + "thoughtForDuration": "Thought for {seconds}s", + "@thoughtForDuration": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, "mainTabProjects": "PROJECTS", "mainTabSettings": "SETTINGS", "serverConfigConnectServer": "Connect Server", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a4b56bc..9a4b724 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -248,6 +248,24 @@ abstract class AppLocalizations { /// **'Also when app is in foreground'** String get sessionCompletionNotificationModeAlways; + /// No description provided for @settingsThinking. + /// + /// In en, this message translates to: + /// **'Auto-expand thinking'** + String get settingsThinking; + + /// No description provided for @thinkingInProgress. + /// + /// In en, this message translates to: + /// **'Thinking…'** + String get thinkingInProgress; + + /// No description provided for @thoughtForDuration. + /// + /// In en, this message translates to: + /// **'Thought for {seconds}s'** + String thoughtForDuration(int seconds); + /// No description provided for @mainTabProjects. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index cad9b62..6ba9fba 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -86,6 +86,17 @@ class AppLocalizationsEn extends AppLocalizations { String get sessionCompletionNotificationModeAlways => 'Also when app is in foreground'; + @override + String get settingsThinking => 'Auto-expand thinking'; + + @override + String get thinkingInProgress => 'Thinking…'; + + @override + String thoughtForDuration(int seconds) { + return 'Thought for ${seconds}s'; + } + @override String get mainTabProjects => 'PROJECTS'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a567301..86bd9b3 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -83,6 +83,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String get sessionCompletionNotificationModeAlways => '应用在前台时也发送通知'; + @override + String get settingsThinking => '自动展开思考'; + + @override + String get thinkingInProgress => '思考中…'; + + @override + String thoughtForDuration(int seconds) { + return '思考 $seconds 秒'; + } + @override String get mainTabProjects => '项目'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0dbf065..04af338 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -25,6 +25,16 @@ "sessionCompletionNotificationModeNone": "不发送通知", "sessionCompletionNotificationModeBackgroundOnly": "应用在后台时发送通知", "sessionCompletionNotificationModeAlways": "应用在前台时也发送通知", + "settingsThinking": "自动展开思考", + "thinkingInProgress": "思考中…", + "thoughtForDuration": "思考 {seconds} 秒", + "@thoughtForDuration": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, "mainTabProjects": "项目", "mainTabSettings": "设置", "serverConfigConnectServer": "连接服务器", diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index b3b78f7..fdfb061 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -7,6 +7,7 @@ import '../l10n/l10n.dart'; import '../providers/app_language_provider.dart'; import '../providers/session_completion_notification_provider.dart'; import '../providers/server_config_provider.dart'; +import '../providers/thinking_auto_expand_provider.dart'; import '../theme/app_tokens.dart'; class SettingsPage extends ConsumerWidget { @@ -21,6 +22,7 @@ class SettingsPage extends ConsumerWidget { final notificationMode = ref.watch( sessionCompletionNotificationModeProvider, ); + final thinkingAutoExpand = ref.watch(thinkingAutoExpandProvider); final serverUrl = _serverDisplayText( asyncServerConfig.value?.baseUrl ?? 'http://127.0.0.1:4096', ); @@ -73,6 +75,17 @@ class SettingsPage extends ConsumerWidget { context.push('/settings/theme'); }, ), + _SettingsSwitchRow( + icon: Icons.psychology_outlined, + title: l10n.settingsThinking, + iconColor: mutedColor, + value: thinkingAutoExpand, + onChanged: (value) { + ref + .read(thinkingAutoExpandProvider.notifier) + .setAutoExpand(value); + }, + ), _SettingsRow( icon: Icons.notifications_outlined, title: l10n.settingsSessionCompletionNotification, @@ -131,6 +144,58 @@ class SettingsPage extends ConsumerWidget { } } +class _SettingsSwitchRow extends StatelessWidget { + const _SettingsSwitchRow({ + required this.icon, + required this.title, + required this.iconColor, + required this.value, + required this.onChanged, + }); + + final IconData icon; + final String title; + final Color iconColor; + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final tokens = context.tokens; + final titleStyle = TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ); + + return SizedBox( + height: 56, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onChanged(!value), + borderRadius: BorderRadius.circular(tokens.radiusXs), + splashColor: tokens.accent, + highlightColor: tokens.accent, + hoverColor: tokens.accent, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Icon(icon, size: 20, color: iconColor), + const SizedBox(width: 12), + Expanded(child: Text(title, style: titleStyle)), + Switch(value: value, onChanged: onChanged), + ], + ), + ), + ), + ), + ); + } +} + class _SectionTitle extends StatelessWidget { const _SectionTitle({required this.title}); diff --git a/lib/providers/session_provider.dart b/lib/providers/session_provider.dart index 400b91a..c38e932 100644 --- a/lib/providers/session_provider.dart +++ b/lib/providers/session_provider.dart @@ -94,18 +94,31 @@ final class MessageListStateReducer { final newParts = List.from(message.parts); if (partIndex >= 0) { final part = message.parts[partIndex]; - if (part is! TextPart) return current; - newParts[partIndex] = TextPart( - id: part.id, - sessionID: part.sessionID, - messageID: part.messageID, - type: part.type, - text: '${part.text}$delta', - synthetic: part.synthetic, - ignored: part.ignored, - time: part.time, - metadata: part.metadata, - ); + if (part is TextPart) { + newParts[partIndex] = TextPart( + id: part.id, + sessionID: part.sessionID, + messageID: part.messageID, + type: part.type, + text: '${part.text}$delta', + synthetic: part.synthetic, + ignored: part.ignored, + time: part.time, + metadata: part.metadata, + ); + } else if (part is ReasoningPart) { + newParts[partIndex] = ReasoningPart( + id: part.id, + sessionID: part.sessionID, + messageID: part.messageID, + type: part.type, + text: '${part.text}$delta', + metadata: part.metadata, + time: part.time, + ); + } else { + return current; + } } else { newParts.add( TextPart( diff --git a/lib/providers/thinking_auto_expand_provider.dart b/lib/providers/thinking_auto_expand_provider.dart new file mode 100644 index 0000000..c003e11 --- /dev/null +++ b/lib/providers/thinking_auto_expand_provider.dart @@ -0,0 +1,48 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'hydrated_state.dart'; +import 'shared_preferences_provider.dart'; + +const _kThinkingAutoExpandKey = 'thinking_auto_expand_v1'; + +final thinkingAutoExpandProvider = + NotifierProvider( + ThinkingAutoExpandNotifier.new, + ); + +class ThinkingAutoExpandNotifier extends Notifier { + late final HydratedValueController _hydration = + HydratedValueController( + readState: () => state, + writeState: (value) => state = value, + load: () => readThinkingAutoExpandFromStorage( + () => ref.read(sharedPreferencesProvider.future), + ), + persist: _persist, + isMounted: () => ref.mounted, + ); + + @override + bool build() { + _hydration.startRestore(); + return false; + } + + Future setAutoExpand(bool value) async { + if (state == value && !_hydration.isHydrating) return; + await _hydration.setValue(value, waitForHydration: true); + } + + Future _persist(bool value) async { + final prefs = await ref.read(sharedPreferencesProvider.future); + await prefs.setBool(_kThinkingAutoExpandKey, value); + } +} + +Future readThinkingAutoExpandFromStorage( + Future Function() preferencesLoader, +) async { + final prefs = await preferencesLoader(); + return prefs.getBool(_kThinkingAutoExpandKey) ?? false; +} diff --git a/lib/widgets/message/message_bubble.dart b/lib/widgets/message/message_bubble.dart index 10bf635..83b6f1b 100644 --- a/lib/widgets/message/message_bubble.dart +++ b/lib/widgets/message/message_bubble.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../l10n/l10n.dart'; import '../../providers/provider_list_provider.dart'; +import '../../providers/thinking_auto_expand_provider.dart'; import '../../service/api/models/provider.dart'; import '../../service/api/models/message.dart'; import '../../service/api/models/parts.dart' @@ -23,6 +24,9 @@ class MessageBubble extends ConsumerStatefulWidget { final bool Function(ToolPart toolPart)? toolExpandedResolver; final void Function(ToolPart toolPart, bool isExpanded)? onToolExpandedChanged; + final bool? Function(ReasoningPart reasoningPart)? thinkingExpandedResolver; + final void Function(ReasoningPart reasoningPart, bool isExpanded)? + onThinkingExpandedChanged; const MessageBubble({ super.key, @@ -32,6 +36,8 @@ class MessageBubble extends ConsumerStatefulWidget { this.onNavigateToSubSession, this.toolExpandedResolver, this.onToolExpandedChanged, + this.thinkingExpandedResolver, + this.onThinkingExpandedChanged, }); @override @@ -350,6 +356,10 @@ class _MessageBubbleState extends ConsumerState { final lastTextPartIndex = parts.lastIndexWhere( (p) => p is TextPart && p.synthetic != true && p.text.isNotEmpty, ); + final lastAnimatedReasoningIndex = parts.lastIndexWhere( + (p) => p is ReasoningPart && p.text.isNotEmpty, + ); + final thinkingAutoExpand = ref.watch(thinkingAutoExpandProvider); final imagePreviewContexts = _buildImagePreviewContexts(parts); return Padding( @@ -358,28 +368,14 @@ class _MessageBubbleState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ for (var i = 0; i < parts.length; i++) ...[ - MessagePart( - key: _partKey(parts[i], i), - part: parts[i], - isUser: false, + _buildAssistantMessagePart( + parts, + index: i, isStreaming: isStreaming, - animateText: - widget.isLatestMessage && - isStreaming && - i == lastAnimatedTextPartIndex, - onNavigateToSubSession: widget.onNavigateToSubSession, - toolIsExpanded: parts[i] is ToolPart - ? widget.toolExpandedResolver?.call(parts[i] as ToolPart) - : null, - onToolExpandedChanged: parts[i] is ToolPart - ? (isExpanded) => widget.onToolExpandedChanged?.call( - parts[i] as ToolPart, - isExpanded, - ) - : null, - imagePreviewUrls: imagePreviewContexts[i]?.urls, - imagePreviewInitialIndex: - imagePreviewContexts[i]?.initialIndex ?? 0, + lastAnimatedTextPartIndex: lastAnimatedTextPartIndex, + lastAnimatedReasoningIndex: lastAnimatedReasoningIndex, + thinkingAutoExpand: thinkingAutoExpand, + imagePreviewContexts: imagePreviewContexts, ), if (i == lastTextPartIndex) ...[ const SizedBox(height: 6), @@ -396,6 +392,50 @@ class _MessageBubbleState extends ConsumerState { ); } + Widget _buildAssistantMessagePart( + List parts, { + required int index, + required bool isStreaming, + required int lastAnimatedTextPartIndex, + required int lastAnimatedReasoningIndex, + required bool thinkingAutoExpand, + required Map imagePreviewContexts, + }) { + final part = parts[index]; + final thinkingExpanded = part is ReasoningPart + ? (widget.thinkingExpandedResolver?.call(part) ?? thinkingAutoExpand) + : null; + return MessagePart( + key: _partKey(part, index), + part: part, + isUser: false, + isStreaming: isStreaming, + animateText: + widget.isLatestMessage && + isStreaming && + index == lastAnimatedTextPartIndex, + animateThinking: + widget.isLatestMessage && + isStreaming && + index == lastAnimatedReasoningIndex && + (thinkingExpanded ?? false), + onNavigateToSubSession: widget.onNavigateToSubSession, + toolIsExpanded: part is ToolPart + ? widget.toolExpandedResolver?.call(part) + : null, + onToolExpandedChanged: part is ToolPart + ? (isExpanded) => widget.onToolExpandedChanged?.call(part, isExpanded) + : null, + thinkingIsExpanded: thinkingExpanded, + onThinkingExpandedChanged: part is ReasoningPart + ? (isExpanded) => + widget.onThinkingExpandedChanged?.call(part, isExpanded) + : null, + imagePreviewUrls: imagePreviewContexts[index]?.urls, + imagePreviewInitialIndex: imagePreviewContexts[index]?.initialIndex ?? 0, + ); + } + Map _buildImagePreviewContexts( List parts, ) { diff --git a/lib/widgets/message/message_list.dart b/lib/widgets/message/message_list.dart index dc56c3e..e402204 100644 --- a/lib/widgets/message/message_list.dart +++ b/lib/widgets/message/message_list.dart @@ -90,6 +90,7 @@ class _MessageListViewState extends State { _PendingScrollAction _pendingScrollAction = _PendingScrollAction.none; bool _scrollActionScheduled = false; final Map _toolExpandedStates = {}; + final Map _thinkingExpandedStates = {}; @override void initState() { @@ -283,6 +284,18 @@ class _MessageListViewState extends State { }); } + bool? _thinkingIsExpanded(ReasoningPart reasoningPart) => + _thinkingExpandedStates[reasoningPart.id]; + + void _setThinkingExpanded(ReasoningPart reasoningPart, bool isExpanded) { + if (_thinkingExpandedStates[reasoningPart.id] == isExpanded) { + return; + } + setState(() { + _thinkingExpandedStates[reasoningPart.id] = isExpanded; + }); + } + @override Widget build(BuildContext context) { final sourceMessages = (!_autoFollowBottom && _isDetachedFromBottom) @@ -335,6 +348,8 @@ class _MessageListViewState extends State { onNavigateToSubSession: widget.onNavigateToSubSession, toolExpandedResolver: _toolIsExpanded, onToolExpandedChanged: _setToolExpanded, + thinkingExpandedResolver: _thinkingIsExpanded, + onThinkingExpandedChanged: _setThinkingExpanded, ); }, ), @@ -449,6 +464,14 @@ String _bottomAnchorSignature(MessageWithParts message) { ..write(part.text.length); continue; } + if (part is ReasoningPart) { + buffer + ..write('|reasoning:') + ..write(part.id) + ..write(':') + ..write(part.text.length); + continue; + } if (part is ToolPart) { buffer ..write('|tool:') diff --git a/lib/widgets/message/message_part.dart b/lib/widgets/message/message_part.dart index 1fd9601..e45697f 100644 --- a/lib/widgets/message/message_part.dart +++ b/lib/widgets/message/message_part.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:markdown/markdown.dart' as md; +import '../../l10n/l10n.dart'; import '../../service/api/models/parts.dart'; import '../../theme/app_tokens.dart'; import 'code_block_widget.dart'; @@ -16,9 +17,12 @@ class MessagePart extends StatelessWidget { final bool isUser; final bool isStreaming; final bool animateText; + final bool animateThinking; final void Function(String sessionId)? onNavigateToSubSession; final bool? toolIsExpanded; final ValueChanged? onToolExpandedChanged; + final bool? thinkingIsExpanded; + final ValueChanged? onThinkingExpandedChanged; final List? imagePreviewUrls; final int imagePreviewInitialIndex; @@ -28,9 +32,12 @@ class MessagePart extends StatelessWidget { required this.isUser, this.isStreaming = false, this.animateText = false, + this.animateThinking = false, this.onNavigateToSubSession, this.toolIsExpanded, this.onToolExpandedChanged, + this.thinkingIsExpanded, + this.onThinkingExpandedChanged, this.imagePreviewUrls, this.imagePreviewInitialIndex = 0, }); @@ -40,6 +47,20 @@ class MessagePart extends StatelessWidget { if (part is CompactionPart) { return const _CompactionDivider(); } + if (part is ReasoningPart) { + final reasoningPart = part as ReasoningPart; + return Padding( + padding: const EdgeInsets.only(top: 4), + child: ThinkingBlockWidget( + key: ValueKey('thinking-${reasoningPart.id}'), + part: reasoningPart, + isStreaming: isStreaming, + isExpanded: thinkingIsExpanded ?? false, + animate: animateThinking, + onExpandedChanged: onThinkingExpandedChanged, + ), + ); + } if (part is TextPart) { final textPart = part as TextPart; if (textPart.synthetic == true && !isStreaming) { @@ -83,11 +104,13 @@ const Key kMessageImageGalleryKey = ValueKey('message-image-gallery'); class _TypewriterMarkdownText extends StatefulWidget { final String text; final bool animate; + final MarkdownStyleSheet? styleSheet; const _TypewriterMarkdownText({ super.key, required this.text, required this.animate, + this.styleSheet, }); @override @@ -112,7 +135,7 @@ class _TypewriterMarkdownTextState extends State<_TypewriterMarkdownText> { void initState() { super.initState(); _chars = widget.text.characters.toList(); - _visibleCount = widget.animate ? 0 : _chars.length; + _visibleCount = _chars.length; _syncAnimation(); } @@ -309,6 +332,8 @@ class _TypewriterMarkdownTextState extends State<_TypewriterMarkdownText> { @override Widget build(BuildContext context) { final text = _chars.take(_visibleCount).join(); + final styleSheet = + widget.styleSheet ?? buildMessageMarkdownStyleSheet(context); return RepaintBoundary( child: MarkdownBody( @@ -316,11 +341,11 @@ class _TypewriterMarkdownTextState extends State<_TypewriterMarkdownText> { selectable: true, builders: { 'pre': CodeBlockBuilder(), - 'ul': _MarkdownListBuilder(ordered: false), - 'ol': _MarkdownListBuilder(ordered: true), + 'ul': _MarkdownListBuilder(ordered: false, styleSheet: styleSheet), + 'ol': _MarkdownListBuilder(ordered: true, styleSheet: styleSheet), }, onTapLink: (text, href, title) => openMessageMarkdownLink(href), - styleSheet: buildMessageMarkdownStyleSheet(context), + styleSheet: styleSheet, ), ); } @@ -328,8 +353,9 @@ class _TypewriterMarkdownTextState extends State<_TypewriterMarkdownText> { class _MarkdownListBuilder extends MarkdownElementBuilder { final bool ordered; + final MarkdownStyleSheet? styleSheet; - _MarkdownListBuilder({required this.ordered}); + _MarkdownListBuilder({required this.ordered, this.styleSheet}); @override bool isBlockElement() => true; @@ -354,7 +380,8 @@ class _MarkdownListBuilder extends MarkdownElementBuilder { : 1; final theme = Theme.of(context); final tokens = context.tokens; - final styleSheet = buildMessageMarkdownStyleSheet(context); + final styleSheet = + this.styleSheet ?? buildMessageMarkdownStyleSheet(context); final bulletStyle = styleSheet.listBullet ?? theme.textTheme.bodyMedium?.copyWith(fontSize: 14, height: 1.45); @@ -373,6 +400,7 @@ class _MarkdownListBuilder extends MarkdownElementBuilder { .where((child) => child.tag == 'ul' || child.tag == 'ol') .toList() ?? const [], + styleSheet: styleSheet, ) else _MarkdownListItem( @@ -387,6 +415,7 @@ class _MarkdownListBuilder extends MarkdownElementBuilder { const [], textColor: preferredStyle?.color ?? theme.colorScheme.onSurface, markerColor: tokens.mutedForeground, + styleSheet: styleSheet, ), if (i != items.length - 1) const SizedBox(height: 4), ], @@ -399,20 +428,23 @@ class _MarkdownTaskListItem extends StatelessWidget { final bool checked; final String contentMarkdown; final List nestedLists; + final MarkdownStyleSheet styleSheet; const _MarkdownTaskListItem({ required this.checked, required this.contentMarkdown, required this.nestedLists, + required this.styleSheet, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final tokens = context.tokens; - final styleSheet = buildMessageMarkdownStyleSheet( - context, - ).copyWith(blockSpacing: 0, pPadding: EdgeInsets.zero); + final styleSheet = this.styleSheet.copyWith( + blockSpacing: 0, + pPadding: EdgeInsets.zero, + ); final checkboxBackground = checked ? theme.colorScheme.primary : theme.colorScheme.surface; @@ -466,8 +498,14 @@ class _MarkdownTaskListItem extends StatelessWidget { selectable: true, builders: { 'pre': CodeBlockBuilder(), - 'ul': _MarkdownListBuilder(ordered: false), - 'ol': _MarkdownListBuilder(ordered: true), + 'ul': _MarkdownListBuilder( + ordered: false, + styleSheet: styleSheet, + ), + 'ol': _MarkdownListBuilder( + ordered: true, + styleSheet: styleSheet, + ), }, onTapLink: (text, href, title) => openMessageMarkdownLink(href), @@ -488,7 +526,7 @@ class _MarkdownTaskListItem extends StatelessWidget { ), for (final nested in nestedLists) ...[ const SizedBox(height: 4), - _NestedMarkdownList(element: nested), + _NestedMarkdownList(element: nested, styleSheet: styleSheet), ], ], ), @@ -505,6 +543,7 @@ class _MarkdownListItem extends StatelessWidget { final List nestedLists; final Color textColor; final Color markerColor; + final MarkdownStyleSheet styleSheet; const _MarkdownListItem({ required this.marker, @@ -513,13 +552,15 @@ class _MarkdownListItem extends StatelessWidget { required this.nestedLists, required this.textColor, required this.markerColor, + required this.styleSheet, }); @override Widget build(BuildContext context) { - final styleSheet = buildMessageMarkdownStyleSheet( - context, - ).copyWith(blockSpacing: 0, pPadding: EdgeInsets.zero); + final styleSheet = this.styleSheet.copyWith( + blockSpacing: 0, + pPadding: EdgeInsets.zero, + ); return Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -545,8 +586,14 @@ class _MarkdownListItem extends StatelessWidget { selectable: true, builders: { 'pre': CodeBlockBuilder(), - 'ul': _MarkdownListBuilder(ordered: false), - 'ol': _MarkdownListBuilder(ordered: true), + 'ul': _MarkdownListBuilder( + ordered: false, + styleSheet: styleSheet, + ), + 'ol': _MarkdownListBuilder( + ordered: true, + styleSheet: styleSheet, + ), }, onTapLink: (text, href, title) => openMessageMarkdownLink(href), @@ -554,7 +601,7 @@ class _MarkdownListItem extends StatelessWidget { ), for (final nested in nestedLists) ...[ const SizedBox(height: 4), - _NestedMarkdownList(element: nested), + _NestedMarkdownList(element: nested, styleSheet: styleSheet), ], ], ), @@ -566,12 +613,16 @@ class _MarkdownListItem extends StatelessWidget { class _NestedMarkdownList extends StatelessWidget { final md.Element element; + final MarkdownStyleSheet? styleSheet; - const _NestedMarkdownList({required this.element}); + const _NestedMarkdownList({required this.element, this.styleSheet}); @override Widget build(BuildContext context) { - final builder = _MarkdownListBuilder(ordered: element.tag == 'ol'); + final builder = _MarkdownListBuilder( + ordered: element.tag == 'ol', + styleSheet: styleSheet, + ); return builder.visitElementAfterWithContext(context, element, null, null) ?? const SizedBox.shrink(); } @@ -793,3 +844,174 @@ class MessageImageGallery extends StatelessWidget { ); } } + +const Key kThinkingBlockHeaderKey = Key('thinking_block.header'); +const Key kThinkingBlockBodyKey = Key('thinking_block.body'); + +class ThinkingBlockWidget extends StatelessWidget { + final ReasoningPart part; + final bool isStreaming; + final bool isExpanded; + final ValueChanged? onExpandedChanged; + final bool animate; + + const ThinkingBlockWidget({ + super.key, + required this.part, + required this.isStreaming, + required this.isExpanded, + this.onExpandedChanged, + this.animate = false, + }); + + @override + Widget build(BuildContext context) { + final tokens = context.tokens; + + return Container( + decoration: BoxDecoration( + color: tokens.accent, + borderRadius: BorderRadius.circular(tokens.radiusXs), + border: Border.all(color: tokens.border.withValues(alpha: 0.8)), + ), + clipBehavior: Clip.hardEdge, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ThinkingHeader( + part: part, + isStreaming: isStreaming, + isExpanded: isExpanded, + onToggle: onExpandedChanged == null + ? null + : () => onExpandedChanged!(!isExpanded), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 160), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => + SizeTransition(sizeFactor: animation, child: child), + child: isExpanded && part.text.isNotEmpty + ? Padding( + key: kThinkingBlockBodyKey, + padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), + child: _TypewriterMarkdownText( + key: ValueKey('thinking-body-${part.id}'), + text: part.text, + animate: animate && part.time.end == null, + styleSheet: _buildThinkingMarkdownStyleSheet(context), + ), + ) + : const SizedBox(width: double.infinity), + ), + ], + ), + ); + } +} + +class _ThinkingHeader extends StatelessWidget { + final ReasoningPart part; + final bool isStreaming; + final bool isExpanded; + final VoidCallback? onToggle; + + const _ThinkingHeader({ + required this.part, + required this.isStreaming, + required this.isExpanded, + this.onToggle, + }); + + String _label(BuildContext context) { + final time = part.time; + final start = time.start; + final end = time.end; + if (part.text.isEmpty && (isStreaming || end == null)) { + return context.l10n.thinkingInProgress; + } + if (start == null || end == null) { + return context.l10n.thinkingInProgress; + } + final seconds = ((end - start) / 1000).ceil().clamp(1, 86400); + return context.l10n.thoughtForDuration(seconds); + } + + @override + Widget build(BuildContext context) { + final tokens = context.tokens; + final inProgress = isStreaming && part.time.end == null; + + return InkWell( + key: kThinkingBlockHeaderKey, + onTap: onToggle, + borderRadius: BorderRadius.circular(tokens.radiusXs), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Icon( + Icons.psychology_outlined, + size: 14, + color: tokens.mutedForeground, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + _label(context), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: tokens.mutedForeground, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (inProgress) ...[ + const SizedBox(width: 6), + SizedBox( + width: 10, + height: 10, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: tokens.mutedForeground, + ), + ), + ], + const SizedBox(width: 4), + AnimatedRotation( + turns: isExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 160), + child: Icon( + Icons.keyboard_arrow_down_rounded, + size: 16, + color: tokens.mutedForeground, + ), + ), + ], + ), + ), + ); + } +} + +MarkdownStyleSheet _buildThinkingMarkdownStyleSheet(BuildContext context) { + final tokens = context.tokens; + final base = buildMessageMarkdownStyleSheet(context); + final body = base.p?.copyWith( + fontSize: 13, + height: 1.55, + color: tokens.mutedForeground, + ); + + return base.copyWith( + p: body, + strong: body?.copyWith(fontWeight: FontWeight.w600), + em: body?.copyWith(fontStyle: FontStyle.italic), + a: body?.copyWith(fontWeight: FontWeight.w500), + listBullet: body?.copyWith(fontSize: 13), + blockSpacing: 8, + ); +} diff --git a/test/session_provider_test.dart b/test/session_provider_test.dart index d3f60b6..c6ba7ce 100644 --- a/test/session_provider_test.dart +++ b/test/session_provider_test.dart @@ -566,4 +566,161 @@ void main() { expect((updated.parts.single as TextPart).text, 'hello world'); }, ); + + test( + 'appendPartDelta with text field appends to existing reasoning part', + () async { + final initial = msg.MessageWithParts( + info: _messageWithText( + sessionID: _kSessionId, + messageID: _kMessageId, + text: 'placeholder', + ).info, + parts: [ + _reasoningPart( + sessionID: _kSessionId, + messageID: _kMessageId, + partID: 'part-reasoning', + text: 'start', + ), + ], + ); + final api = _FakeSessionApi(>{ + _kSessionId: [initial], + }); + final container = ProviderContainer( + overrides: [sessionApiProvider.overrideWith((ref) async => api)], + ); + addTearDown(container.dispose); + + await container.read(subSessionMessagesProvider(_kSessionId).future); + + container + .read(subSessionMessagesProvider(_kSessionId).notifier) + .appendPartDelta( + _kSessionId, + _kMessageId, + 'part-reasoning', + 'text', + ' more', + ); + + final updated = container + .read(subSessionMessagesProvider(_kSessionId)) + .requireValue + .single; + expect(updated.parts, hasLength(1)); + expect(updated.parts.single, isA()); + expect((updated.parts.single as ReasoningPart).text, 'start more'); + }, + ); + + test('appendPartDelta ignores unknown delta field', () async { + final initial = _messageWithText( + sessionID: _kSessionId, + messageID: _kMessageId, + text: 'hello', + ); + final api = _FakeSessionApi(>{ + _kSessionId: [initial], + }); + final container = ProviderContainer( + overrides: [sessionApiProvider.overrideWith((ref) async => api)], + ); + addTearDown(container.dispose); + + await container.read(subSessionMessagesProvider(_kSessionId).future); + + container + .read(subSessionMessagesProvider(_kSessionId).notifier) + .appendPartDelta( + _kSessionId, + _kMessageId, + 'part-$_kMessageId', + 'tool', + 'x', + ); + + final updated = container + .read(subSessionMessagesProvider(_kSessionId)) + .requireValue + .single; + expect(updated.parts, hasLength(1)); + expect((updated.parts.single as TextPart).text, 'hello'); + }); + + test( + 'global message.part.delta with text field appends to reasoning part', + () async { + final initial = msg.MessageWithParts( + info: _messageWithText( + sessionID: _kSessionId, + messageID: _kMessageId, + text: 'placeholder', + ).info, + parts: [ + _reasoningPart( + sessionID: _kSessionId, + messageID: _kMessageId, + partID: 'part-reasoning', + text: 'start', + ), + ], + ); + final sessionApi = _FakeSessionApi(>{ + _kSessionId: [initial], + }); + final controller = StreamController(); + final globalApi = _FakeGlobalApi(controller); + final container = ProviderContainer( + overrides: [ + sessionApiProvider.overrideWith((ref) async => sessionApi), + globalApiProvider.overrideWith((ref) async => globalApi), + ], + ); + addTearDown(() async { + await controller.close(); + container.dispose(); + }); + + await container.read(sessionMessagesProvider(_kSessionId).future); + container.read(currentDirectoryProvider.notifier).set(_kDirectory); + final sessionState = container + .listen>>( + sessionMessagesProvider(_kSessionId), + (previous, next) {}, + fireImmediately: true, + ); + addTearDown(sessionState.close); + final sub = container.listen>( + globalEventListenerProvider, + (previous, next) {}, + fireImmediately: true, + ); + addTearDown(sub.close); + await _flushAsyncWork(); + + controller.add( + GlobalEvent( + directory: _kDirectory, + payload: EventMessagePartDelta( + type: 'message.part.delta', + sessionID: _kSessionId, + messageID: _kMessageId, + partID: 'part-reasoning', + field: 'text', + delta: ' more', + ), + ), + ); + await _flushAsyncWork(); + + final updated = container + .read(sessionMessagesProvider(_kSessionId)) + .requireValue + .single; + expect(updated.parts, hasLength(1)); + expect((updated.parts.single as ReasoningPart).text, 'start more'); + }, + ); } diff --git a/test/thinking_auto_expand_provider_test.dart b/test/thinking_auto_expand_provider_test.dart new file mode 100644 index 0000000..b4c48df --- /dev/null +++ b/test/thinking_auto_expand_provider_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flycode/providers/thinking_auto_expand_provider.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test( + 'thinkingAutoExpandProvider defaults to false on fresh install', + () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(thinkingAutoExpandProvider), isFalse); + + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(container.read(thinkingAutoExpandProvider), isFalse); + }, + ); + + test('setAutoExpand updates state and persists to storage', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + await container + .read(thinkingAutoExpandProvider.notifier) + .setAutoExpand(true); + expect(container.read(thinkingAutoExpandProvider), isTrue); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('thinking_auto_expand_v1'), isTrue); + + await container + .read(thinkingAutoExpandProvider.notifier) + .setAutoExpand(false); + expect(container.read(thinkingAutoExpandProvider), isFalse); + expect( + (await SharedPreferences.getInstance()).getBool( + 'thinking_auto_expand_v1', + ), + isFalse, + ); + }); + + test( + 'thinkingAutoExpandProvider restores persisted value asynchronously', + () async { + SharedPreferences.setMockInitialValues({ + 'thinking_auto_expand_v1': true, + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(thinkingAutoExpandProvider), isFalse); + + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(container.read(thinkingAutoExpandProvider), isTrue); + }, + ); +} diff --git a/test/thinking_block_widget_test.dart b/test/thinking_block_widget_test.dart new file mode 100644 index 0000000..562e8a4 --- /dev/null +++ b/test/thinking_block_widget_test.dart @@ -0,0 +1,149 @@ +import 'package:flycode/l10n/app_localizations.dart'; +import 'package:flycode/service/api/models/parts.dart'; +import 'package:flycode/theme/app_theme.dart'; +import 'package:flycode/widgets/message/message_part.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +ReasoningPart _reasoningPart({ + String id = 'reasoning-1', + required String text, + int? start = 1000, + int? end, +}) { + return ReasoningPart( + id: id, + sessionID: 'session-1', + messageID: 'message-1', + type: 'reasoning', + text: text, + time: PartTime(start: start, end: end), + ); +} + +Widget _buildHarness(Widget child) { + return MaterialApp( + theme: AppTheme.light(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 360, child: child), + ), + ), + ); +} + +void main() { + testWidgets('completed reasoning shows duration label and stays collapsed', ( + tester, + ) async { + final part = _reasoningPart(text: 'chain of thought', end: 11000); + + await tester.pumpWidget( + _buildHarness( + ThinkingBlockWidget(part: part, isStreaming: false, isExpanded: false), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Thought for 10s'), findsOneWidget); + expect(find.text('chain of thought'), findsNothing); + }); + + testWidgets('streaming reasoning shows in-progress label', (tester) async { + final part = _reasoningPart(text: 'partial thoughts'); + + await tester.pumpWidget( + _buildHarness( + ThinkingBlockWidget(part: part, isStreaming: true, isExpanded: false), + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Thinking…'), findsOneWidget); + expect(find.text('partial thoughts'), findsNothing); + }); + + testWidgets('expanded reasoning renders text body', (tester) async { + final part = _reasoningPart(text: 'rendered reasoning', end: 11000); + + await tester.pumpWidget( + _buildHarness( + ThinkingBlockWidget(part: part, isStreaming: false, isExpanded: true), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Thought for 10s'), findsOneWidget); + expect(find.text('rendered reasoning'), findsOneWidget); + }); + + testWidgets('completed reasoning body renders full text immediately', ( + tester, + ) async { + final part = _reasoningPart(text: 'already finished reasoning', end: 11000); + + await tester.pumpWidget( + _buildHarness( + ThinkingBlockWidget( + part: part, + isStreaming: true, + isExpanded: true, + animate: true, + ), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('already finished reasoning'), findsOneWidget); + }); + + testWidgets('mid-stream expansion shows existing backlog immediately', ( + tester, + ) async { + final part = _reasoningPart(text: 'accumulated while collapsed'); + + await tester.pumpWidget( + _buildHarness( + ThinkingBlockWidget( + part: part, + isStreaming: true, + isExpanded: true, + animate: true, + ), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('accumulated while collapsed'), findsOneWidget); + }); + + testWidgets('tapping header reports expanded via callback', (tester) async { + final part = _reasoningPart(text: 'tap target', end: 11000); + bool? reportedExpanded; + + await tester.pumpWidget( + _buildHarness( + ThinkingBlockWidget( + part: part, + isStreaming: false, + isExpanded: false, + onExpandedChanged: (isExpanded) { + reportedExpanded = isExpanded; + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('tap target'), findsNothing); + + await tester.tap(find.byKey(kThinkingBlockHeaderKey)); + await tester.pumpAndSettle(); + + expect(reportedExpanded, isTrue); + }); +} From 5d7c336ac409a5fbeb086746c31743985bbee5f2 Mon Sep 17 00:00:00 2001 From: Jeffrey Wu Date: Mon, 31 Aug 2026 22:13:19 +0800 Subject: [PATCH 2/2] fix(message): preserve streaming text animation --- lib/widgets/message/message_part.dart | 7 ++++++- test/message_markdown_test.dart | 28 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/widgets/message/message_part.dart b/lib/widgets/message/message_part.dart index e45697f..134ce41 100644 --- a/lib/widgets/message/message_part.dart +++ b/lib/widgets/message/message_part.dart @@ -104,12 +104,14 @@ const Key kMessageImageGalleryKey = ValueKey('message-image-gallery'); class _TypewriterMarkdownText extends StatefulWidget { final String text; final bool animate; + final bool showInitialTextImmediately; final MarkdownStyleSheet? styleSheet; const _TypewriterMarkdownText({ super.key, required this.text, required this.animate, + this.showInitialTextImmediately = false, this.styleSheet, }); @@ -135,7 +137,9 @@ class _TypewriterMarkdownTextState extends State<_TypewriterMarkdownText> { void initState() { super.initState(); _chars = widget.text.characters.toList(); - _visibleCount = _chars.length; + _visibleCount = !widget.animate || widget.showInitialTextImmediately + ? _chars.length + : 0; _syncAnimation(); } @@ -900,6 +904,7 @@ class ThinkingBlockWidget extends StatelessWidget { key: ValueKey('thinking-body-${part.id}'), text: part.text, animate: animate && part.time.end == null, + showInitialTextImmediately: true, styleSheet: _buildThinkingMarkdownStyleSheet(context), ), ) diff --git a/test/message_markdown_test.dart b/test/message_markdown_test.dart index b7ff971..a952e30 100644 --- a/test/message_markdown_test.dart +++ b/test/message_markdown_test.dart @@ -143,6 +143,34 @@ void main() { expect(completedTaskStyle?.decoration, TextDecoration.lineThrough); }); + testWidgets('animates the initial assistant streaming text', (tester) async { + const streamingText = 'Streaming assistant response'; + + await tester.pumpWidget( + buildHarness( + brightness: Brightness.light, + child: MessagePart( + part: TextPart( + id: 'streaming-part', + sessionID: 'session-1', + messageID: 'message-1', + type: 'text', + text: streamingText, + ), + isUser: false, + isStreaming: true, + animateText: true, + ), + ), + ); + + expect(find.text(streamingText), findsNothing); + + await tester.pump(const Duration(seconds: 2)); + + expect(find.text(streamingText), findsOneWidget); + }); + testWidgets('renders fenced code blocks as selectable text', (tester) async { debugMessageMarkdownLinkLauncher = _noopLauncher;