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..134ce41 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,15 @@ 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, }); @override @@ -112,7 +137,9 @@ class _TypewriterMarkdownTextState extends State<_TypewriterMarkdownText> { void initState() { super.initState(); _chars = widget.text.characters.toList(); - _visibleCount = widget.animate ? 0 : _chars.length; + _visibleCount = !widget.animate || widget.showInitialTextImmediately + ? _chars.length + : 0; _syncAnimation(); } @@ -309,6 +336,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 +345,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 +357,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 +384,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 +404,7 @@ class _MarkdownListBuilder extends MarkdownElementBuilder { .where((child) => child.tag == 'ul' || child.tag == 'ol') .toList() ?? const [], + styleSheet: styleSheet, ) else _MarkdownListItem( @@ -387,6 +419,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 +432,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 +502,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 +530,7 @@ class _MarkdownTaskListItem extends StatelessWidget { ), for (final nested in nestedLists) ...[ const SizedBox(height: 4), - _NestedMarkdownList(element: nested), + _NestedMarkdownList(element: nested, styleSheet: styleSheet), ], ], ), @@ -505,6 +547,7 @@ class _MarkdownListItem extends StatelessWidget { final List nestedLists; final Color textColor; final Color markerColor; + final MarkdownStyleSheet styleSheet; const _MarkdownListItem({ required this.marker, @@ -513,13 +556,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 +590,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 +605,7 @@ class _MarkdownListItem extends StatelessWidget { ), for (final nested in nestedLists) ...[ const SizedBox(height: 4), - _NestedMarkdownList(element: nested), + _NestedMarkdownList(element: nested, styleSheet: styleSheet), ], ], ), @@ -566,12 +617,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 +848,175 @@ 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, + showInitialTextImmediately: true, + 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/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; 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); + }); +}