From 03c2b030e10378d49c67dcdd57a7f7d5c40d735f Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sun, 7 Dec 2025 23:33:26 +0530 Subject: [PATCH] feat(chat): Simplify tool call tile with minimal design --- .../widgets/assistant_message_widget.dart | 357 +++++---- .../chat/widgets/streaming_status_widget.dart | 687 +++++++----------- 2 files changed, 413 insertions(+), 631 deletions(-) diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 53a8d66..4d9718b 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -376,6 +376,7 @@ class _AssistantMessageWidgetState extends ConsumerState // No streaming-specific markdown fixes needed here; handled by Markdown widget + // Tool call tile - minimal design inspired by OpenWebUI Widget _buildToolCallTile(ToolCallEntry tc) { final isExpanded = _expandedToolIds.contains(tc.id); final theme = context.conduitTheme; @@ -394,7 +395,7 @@ class _AssistantMessageWidgetState extends ConsumerState return Padding( padding: const EdgeInsets.only(bottom: Spacing.xs), - child: InkWell( + child: GestureDetector( onTap: () { setState(() { if (isExpanded) { @@ -404,127 +405,111 @@ class _AssistantMessageWidgetState extends ConsumerState } }); }, - borderRadius: BorderRadius.circular(AppBorderRadius.small), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(AppBorderRadius.small), - border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.5), - width: BorderWidth.thin, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isExpanded - ? Icons.expand_less_rounded - : Icons.expand_more_rounded, - size: 16, - color: theme.textSecondary, - ), - const SizedBox(width: Spacing.xs), - Icon( - tc.done - ? Icons.build_circle_outlined - : Icons.play_circle_outline, - size: 14, - color: theme.buttonPrimary, - ), - const SizedBox(width: Spacing.xs), - Flexible( - child: Text( - tc.done - ? 'Tool Executed: ${tc.name}' - : 'Running tool: ${tc.name}…', - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontWeight: FontWeight.w500, - ), + behavior: HitTestBehavior.opaque, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Minimal header - just text with chevron + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + isExpanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 14, + color: theme.textSecondary, + ), + const SizedBox(width: 2), + Flexible( + child: Text( + tc.done ? 'Used ${tc.name}' : 'Running ${tc.name}…', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textSecondary, + height: 1.3, ), ), - ], - ), - - AnimatedCrossFade( - firstChild: const SizedBox.shrink(), - secondChild: Container( - margin: const EdgeInsets.only(top: Spacing.sm), - padding: const EdgeInsets.all(Spacing.sm), - decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(AppBorderRadius.small), - border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.5), - width: BorderWidth.thin, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (tc.arguments != null) ...[ - Text( - 'Arguments', - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: Spacing.xxs), - SelectableText( - pretty(tc.arguments), - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontFamily: AppTypography.monospaceFontFamily, - height: 1.35, - ), - ), - const SizedBox(height: Spacing.sm), - ], - - if (tc.result != null) ...[ - Text( - 'Result', - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: Spacing.xxs), - SelectableText( - pretty(tc.result), - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontFamily: AppTypography.monospaceFontFamily, - height: 1.35, - ), - ), - ], - ], - ), ), - crossFadeState: isExpanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 200), + ], + ), + + // Expanded content with left border accent + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Container( + margin: const EdgeInsets.only(top: Spacing.xs, left: 16), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(4), + border: Border( + left: BorderSide( + color: theme.dividerColor.withValues(alpha: 0.4), + width: 2, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (tc.arguments != null) ...[ + Text( + 'Arguments', + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: theme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + SelectableText( + pretty(tc.arguments), + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textSecondary, + fontFamily: AppTypography.monospaceFontFamily, + height: 1.35, + ), + ), + if (tc.result != null) const SizedBox(height: Spacing.xs), + ], + + if (tc.result != null) ...[ + Text( + 'Result', + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: theme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + SelectableText( + pretty(tc.result), + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textSecondary, + fontFamily: AppTypography.monospaceFontFamily, + height: 1.35, + ), + ), + ], + ], + ), ), - ], - ), + crossFadeState: isExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + ], ), ), ); @@ -1307,7 +1292,7 @@ class _AssistantMessageWidgetState extends ConsumerState return ChatActionButton(icon: icon, label: label, onTap: onTap); } - // Reasoning tile rendered inline at the position it appears + // Reasoning tile rendered inline - minimal design inspired by OpenWebUI Widget _buildReasoningTile(ReasoningEntry rc, int index) { final isExpanded = _expandedReasoning.contains(index); final theme = context.conduitTheme; @@ -1332,7 +1317,7 @@ class _AssistantMessageWidgetState extends ConsumerState return Padding( padding: const EdgeInsets.only(bottom: Spacing.xs), - child: InkWell( + child: GestureDetector( onTap: () { setState(() { if (isExpanded) { @@ -1342,85 +1327,73 @@ class _AssistantMessageWidgetState extends ConsumerState } }); }, - borderRadius: BorderRadius.circular(AppBorderRadius.small), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(AppBorderRadius.small), - border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.5), - width: BorderWidth.thin, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isExpanded - ? Icons.expand_less_rounded - : Icons.expand_more_rounded, - size: 16, - color: theme.textSecondary, - ), - const SizedBox(width: Spacing.xs), - Icon( - Icons.psychology_outlined, - size: 14, - color: theme.buttonPrimary, - ), - const SizedBox(width: Spacing.xs), - Flexible( - child: Text( - headerText(), - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - - AnimatedCrossFade( - firstChild: const SizedBox.shrink(), - secondChild: Container( - margin: const EdgeInsets.only(top: Spacing.sm), - padding: const EdgeInsets.all(Spacing.sm), - decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(AppBorderRadius.small), - border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.5), - width: BorderWidth.thin, - ), - ), - child: SelectableText( - rc.cleanedReasoning, + behavior: HitTestBehavior.opaque, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Minimal header - just text with chevron + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + isExpanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 14, + color: theme.textSecondary, + ), + const SizedBox(width: 2), + Flexible( + child: Text( + headerText(), + overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppTypography.bodySmall, color: theme.textSecondary, - fontFamily: AppTypography.monospaceFontFamily, - height: 1.4, + height: 1.3, ), ), ), - crossFadeState: isExpanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 200), + ], + ), + + // Expanded content - subtle background only when shown + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Container( + margin: const EdgeInsets.only(top: Spacing.xs, left: 16), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(4), + border: Border( + left: BorderSide( + color: theme.dividerColor.withValues(alpha: 0.4), + width: 2, + ), + ), + ), + child: SelectableText( + rc.cleanedReasoning, + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textSecondary, + fontFamily: AppTypography.monospaceFontFamily, + height: 1.4, + ), + ), ), - ], - ), + crossFadeState: isExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + ], ), ), ); diff --git a/lib/features/chat/widgets/streaming_status_widget.dart b/lib/features/chat/widgets/streaming_status_widget.dart index 690297c..327fb1e 100644 --- a/lib/features/chat/widgets/streaming_status_widget.dart +++ b/lib/features/chat/widgets/streaming_status_widget.dart @@ -3,11 +3,12 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:url_launcher/url_launcher_string.dart'; import '../../../core/models/chat_message.dart'; -import '../../../shared/theme/theme_extensions.dart'; import '../../../core/utils/debug_logger.dart'; +import '../../../shared/theme/theme_extensions.dart'; -/// A modern, mobile-first streaming status widget that displays -/// live status updates during AI response generation. +/// A minimal, unobtrusive streaming status widget inspired by OpenWebUI. +/// Displays live status updates during AI response generation without +/// drawing focus away from the actual response content. class StreamingStatusWidget extends StatefulWidget { const StreamingStatusWidget({ super.key, @@ -64,53 +65,33 @@ class _StreamingStatusWidgetState extends State final current = visible.last; final hasPrevious = visible.length > 1; + final isPending = current.done != true && widget.isStreaming; - final theme = context.conduitTheme; - - return InkWell( + return GestureDetector( onTap: hasPrevious ? () => setState(() => _expanded = !_expanded) : null, - borderRadius: BorderRadius.circular(AppBorderRadius.small), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(AppBorderRadius.small), - border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.5), - width: BorderWidth.thin, - ), - ), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(bottom: Spacing.xs), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - // Current status header (always visible) - _StatusRow( + // Current status (always visible) - minimal text only + _MinimalStatusRow( update: current, - isPending: current.done != true && widget.isStreaming, + isPending: isPending, shimmerController: _shimmerController, - showExpandIcon: hasPrevious, + hasPrevious: hasPrevious, isExpanded: _expanded, ), - // Expandable timeline with full history - AnimatedCrossFade( - firstChild: const SizedBox.shrink(), - secondChild: hasPrevious - ? _HistoryTimeline( - updates: visible, - shimmerController: _shimmerController, - isStreaming: widget.isStreaming, - ) - : const SizedBox.shrink(), - crossFadeState: _expanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 200), - ), + // Expanded history timeline + if (_expanded && hasPrevious) + _MinimalHistoryTimeline( + updates: visible, + shimmerController: _shimmerController, + isStreaming: widget.isStreaming, + ), ], ), ), @@ -118,20 +99,20 @@ class _StreamingStatusWidgetState extends State } } -/// Status row with expand chevron (header when collapsed). -class _StatusRow extends StatelessWidget { - const _StatusRow({ +/// Minimal status row - just text with optional chevron. +class _MinimalStatusRow extends StatelessWidget { + const _MinimalStatusRow({ required this.update, required this.isPending, required this.shimmerController, - this.showExpandIcon = false, - this.isExpanded = false, + required this.hasPrevious, + required this.isExpanded, }); final ChatStatusUpdate update; final bool isPending; final AnimationController shimmerController; - final bool showExpandIcon; + final bool hasPrevious; final bool isExpanded; @override @@ -139,61 +120,82 @@ class _StatusRow extends StatelessWidget { final theme = context.conduitTheme; final queries = _collectQueries(update); final links = _collectLinks(update); + final description = _resolveStatusDescription(update); return Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - // Header row + // Main status text Row( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Expand chevron - if (showExpandIcon) + if (hasPrevious) ...[ Icon( isExpanded - ? Icons.expand_less_rounded - : Icons.expand_more_rounded, - size: 16, + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 14, color: theme.textSecondary, ), - if (showExpandIcon) const SizedBox(width: Spacing.xs), - // Status icon - _StatusIcon( - action: update.action, - isPending: isPending, - shimmerController: shimmerController, - ), - const SizedBox(width: Spacing.xs), - // Status text - Flexible( - child: _StatusText( - update: update, - isPending: isPending, - shimmerController: shimmerController, - ), - ), + const SizedBox(width: 2), + ], + Flexible(child: _buildStatusText(context, description, isPending)), ], ), - // Query pills (only when collapsed) - if (!isExpanded && queries.isNotEmpty) ...[ - const SizedBox(height: Spacing.xs), - _QueryChips(queries: queries), + // Query pills (inline, compact) + if (queries.isNotEmpty && !isExpanded) ...[ + const SizedBox(height: Spacing.xxs), + _MinimalQueryChips(queries: queries), ], - // Source links (only when collapsed) - if (!isExpanded && links.isNotEmpty) ...[ - const SizedBox(height: Spacing.xs), - _SourceLinks(links: links), + // Source links (inline, compact) + if (links.isNotEmpty && !isExpanded) ...[ + const SizedBox(height: Spacing.xxs), + _MinimalSourceLinks(links: links), ], ], ); } + + Widget _buildStatusText( + BuildContext context, + String description, + bool isPending, + ) { + final theme = context.conduitTheme; + final baseStyle = TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textSecondary, + height: 1.3, + ); + + if (!isPending) { + return Text(description, style: baseStyle, maxLines: 1); + } + + // Subtle shimmer for pending state + return AnimatedBuilder( + animation: shimmerController, + builder: (context, child) { + final opacity = 0.6 + (0.4 * (1.0 - shimmerController.value)); + return Text( + description, + style: baseStyle.copyWith( + color: theme.textSecondary.withValues(alpha: opacity), + ), + maxLines: 1, + ); + }, + ); + } } -/// Full history timeline matching the web client. -class _HistoryTimeline extends StatelessWidget { - const _HistoryTimeline({ +/// Minimal timeline for expanded history - small dots like OpenWebUI. +class _MinimalHistoryTimeline extends StatelessWidget { + const _MinimalHistoryTimeline({ required this.updates, required this.shimmerController, required this.isStreaming, @@ -205,213 +207,104 @@ class _HistoryTimeline extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = context.conduitTheme; + return Padding( - padding: const EdgeInsets.only(top: Spacing.sm), + padding: const EdgeInsets.only(top: Spacing.xs, left: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...updates.asMap().entries.map((entry) { - final index = entry.key; - final update = entry.value; - final isLast = index == updates.length - 1; - final isPending = isLast && update.done != true && isStreaming; + mainAxisSize: MainAxisSize.min, + children: updates.asMap().entries.map((entry) { + final index = entry.key; + final update = entry.value; + final isLast = index == updates.length - 1; + final isPending = isLast && update.done != true && isStreaming; + final description = _resolveStatusDescription(update); + final queries = _collectQueries(update); + final links = _collectLinks(update); - return _TimelineItem( - update: update, - isPending: isPending, - shimmerController: shimmerController, - isLast: isLast, - ); - }), - ], - ), - ); - } -} - -/// Single timeline item with dot and connecting line. -class _TimelineItem extends StatelessWidget { - const _TimelineItem({ - required this.update, - required this.isPending, - required this.shimmerController, - required this.isLast, - }); - - final ChatStatusUpdate update; - final bool isPending; - final AnimationController shimmerController; - final bool isLast; - - @override - Widget build(BuildContext context) { - final theme = context.conduitTheme; - final queries = _collectQueries(update); - final links = _collectLinks(update); - - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Timeline indicator (dot + line) - SizedBox( - width: 16, - child: Column( + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Dot - Container( - margin: const EdgeInsets.only(top: Spacing.xs), - width: 6, - height: 6, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isPending - ? theme.buttonPrimary - : theme.textTertiary.withValues(alpha: 0.6), + // Timeline dot and line + SizedBox( + width: 12, + child: Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 5), + width: 5, + height: 5, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.textSecondary.withValues(alpha: 0.6), + ), + ), + if (!isLast) + Expanded( + child: Container( + width: 0.5, + margin: const EdgeInsets.symmetric(vertical: 2), + color: theme.dividerColor.withValues(alpha: 0.4), + ), + ), + ], ), ), - // Connecting line (not on last item) - if (!isLast) - Expanded( - child: Container( - width: 1, - margin: const EdgeInsets.symmetric(vertical: 2), - color: theme.dividerColor.withValues(alpha: 0.5), + const SizedBox(width: Spacing.xs), + // Content + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: Spacing.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildStatusText(context, description, isPending), + if (queries.isNotEmpty) ...[ + const SizedBox(height: 2), + _MinimalQueryChips(queries: queries), + ], + if (links.isNotEmpty) ...[ + const SizedBox(height: 2), + _MinimalSourceLinks(links: links), + ], + ], ), ), + ), ], ), - ), - const SizedBox(width: Spacing.xs), - // Content - Expanded( - child: Padding( - padding: const EdgeInsets.only(bottom: Spacing.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Status text - _StatusText( - update: update, - isPending: isPending, - shimmerController: shimmerController, - ), - // Query chips - if (queries.isNotEmpty) ...[ - const SizedBox(height: Spacing.xs), - _QueryChips(queries: queries), - ], - // Source links - if (links.isNotEmpty) ...[ - const SizedBox(height: Spacing.xs), - _SourceLinks(links: links), - ], - ], - ), - ), - ), - ], + ); + }).toList(), ), ); } -} -/// Status icon matching the reasoning tile style. -class _StatusIcon extends StatelessWidget { - const _StatusIcon({ - required this.action, - required this.isPending, - required this.shimmerController, - }); - - final String? action; - final bool isPending; - final AnimationController shimmerController; - - @override - Widget build(BuildContext context) { + Widget _buildStatusText( + BuildContext context, + String description, + bool isPending, + ) { final theme = context.conduitTheme; - final iconData = _getIconForAction(action); - final iconColor = theme.buttonPrimary; + final baseStyle = TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textSecondary, + height: 1.3, + ); - // Simple icon matching reasoning tile (14px, primary color) if (!isPending) { - return Icon(iconData, size: 14, color: iconColor); + return Text(description, style: baseStyle); } - // Subtle pulse animation for pending state return AnimatedBuilder( animation: shimmerController, builder: (context, child) { final opacity = 0.6 + (0.4 * (1.0 - shimmerController.value)); - return Icon( - iconData, - size: 14, - color: iconColor.withValues(alpha: opacity), - ); - }, - ); - } - - IconData _getIconForAction(String? action) { - switch (action) { - case 'web_search': - case 'web_search_queries_generated': - case 'queries_generated': - return Icons.search_rounded; - case 'knowledge_search': - return Icons.menu_book_rounded; - case 'sources_retrieved': - return Icons.source_rounded; - case 'generating': - return Icons.edit_note_rounded; - default: - return Icons.bolt_rounded; - } - } -} - -/// Status text matching the reasoning tile style. -class _StatusText extends StatelessWidget { - const _StatusText({ - required this.update, - required this.isPending, - required this.shimmerController, - }); - - final ChatStatusUpdate update; - final bool isPending; - final AnimationController shimmerController; - - @override - Widget build(BuildContext context) { - final theme = context.conduitTheme; - final description = _resolveStatusDescription(update); - - // Match reasoning tile text style - final textStyle = TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontWeight: FontWeight.w500, - ); - - if (!isPending) { - return Text( - description, - overflow: TextOverflow.ellipsis, - style: textStyle, - ); - } - - // Shimmer effect for pending state - return AnimatedBuilder( - animation: shimmerController, - builder: (context, child) { - final opacity = 0.5 + (0.5 * (1.0 - shimmerController.value)); return Text( description, - overflow: TextOverflow.ellipsis, - style: textStyle.copyWith( + style: baseStyle.copyWith( color: theme.textSecondary.withValues(alpha: opacity), ), ); @@ -420,9 +313,9 @@ class _StatusText extends StatelessWidget { } } -/// Horizontally scrollable query chips. -class _QueryChips extends StatelessWidget { - const _QueryChips({required this.queries}); +/// Minimal query chips - smaller, less prominent. +class _MinimalQueryChips extends StatelessWidget { + const _MinimalQueryChips({required this.queries}); final List queries; @@ -430,69 +323,46 @@ class _QueryChips extends StatelessWidget { Widget build(BuildContext context) { final theme = context.conduitTheme; - return SizedBox( - height: 32, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: queries.length, - separatorBuilder: (_, index) => const SizedBox(width: Spacing.xs), - itemBuilder: (context, index) { - final query = queries[index]; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _launchSearch(query), - borderRadius: BorderRadius.circular(AppBorderRadius.pill), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: theme.buttonPrimary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(AppBorderRadius.pill), - border: Border.all( - color: theme.buttonPrimary.withValues(alpha: 0.2), - width: BorderWidth.thin, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.search_rounded, - size: 14, - color: theme.buttonPrimary, - ), - const SizedBox(width: Spacing.xxs), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 180), - child: Text( - query, - style: TextStyle( - fontSize: AppTypography.labelSmall, - fontWeight: FontWeight.w500, - color: theme.buttonPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], + return Wrap( + spacing: 4, + runSpacing: 4, + children: queries.asMap().entries.map((entry) { + final index = entry.key; + final query = entry.value; + return GestureDetector( + onTap: () => _launchSearch(query), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.search_rounded, + size: 11, + color: theme.textSecondary, + ), + const SizedBox(width: 3), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150), + child: Text( + query, + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: theme.textSecondary, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), - ) - .animate() - .fadeIn(duration: 200.ms, delay: (50 * index).ms) - .slideX( - begin: 0.1, - end: 0, - duration: 200.ms, - delay: (50 * index).ms, - ); - }, - ), + ], + ), + ).animate().fadeIn(duration: 150.ms, delay: (30 * index).ms), + ); + }).toList(), ); } @@ -506,137 +376,83 @@ class _QueryChips extends StatelessWidget { } } -/// Source link chips with favicons. -class _SourceLinks extends StatelessWidget { - const _SourceLinks({required this.links}); +/// Minimal source links - smaller, less prominent. +class _MinimalSourceLinks extends StatelessWidget { + const _MinimalSourceLinks({required this.links}); final List<_LinkData> links; @override Widget build(BuildContext context) { final theme = context.conduitTheme; - - // Show max 4 links, with a "+N more" indicator final displayLinks = links.take(4).toList(); final remaining = links.length - 4; return Wrap( - spacing: Spacing.xs, - runSpacing: Spacing.xs, + spacing: 4, + runSpacing: 4, children: [ ...displayLinks.asMap().entries.map((entry) { final index = entry.key; final link = entry.value; - return _SourceLinkChip(link: link) - .animate() - .fadeIn(duration: 200.ms, delay: (50 * index).ms) - .scale( - begin: const Offset(0.9, 0.9), - end: const Offset(1, 1), - duration: 200.ms, - delay: (50 * index).ms, - ); + final domain = _extractDomain(link.url); + + return GestureDetector( + onTap: () => _launchUrl(link.url), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: Image.network( + 'https://www.google.com/s2/favicons?sz=16&domain=$domain', + width: 12, + height: 12, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.public_rounded, + size: 12, + color: theme.textSecondary, + ), + ), + ), + const SizedBox(width: 4), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 100), + child: Text( + link.title ?? domain, + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: theme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ).animate().fadeIn(duration: 150.ms, delay: (30 * index).ms), + ); }), if (remaining > 0) - Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(AppBorderRadius.sm), - ), - child: Text( - '+$remaining', - style: TextStyle( - fontSize: AppTypography.labelSmall, - color: theme.textSecondary, - fontWeight: FontWeight.w500, - ), + Text( + '+$remaining', + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: theme.textSecondary, ), ).animate().fadeIn( - duration: 200.ms, - delay: (50 * displayLinks.length).ms, + duration: 150.ms, + delay: (30 * displayLinks.length).ms, ), ], ); } -} - -/// Individual source link chip with favicon. -class _SourceLinkChip extends StatelessWidget { - const _SourceLinkChip({required this.link}); - - final _LinkData link; - - @override - Widget build(BuildContext context) { - final theme = context.conduitTheme; - final domain = _extractDomain(link.url); - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _launchUrl(link.url), - borderRadius: BorderRadius.circular(AppBorderRadius.sm), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(AppBorderRadius.sm), - border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.3), - width: BorderWidth.thin, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Favicon - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.network( - 'https://www.google.com/s2/favicons?sz=32&domain=$domain', - width: 14, - height: 14, - errorBuilder: (context, error, stackTrace) => Icon( - Icons.public_rounded, - size: 14, - color: theme.textSecondary, - ), - ), - ), - const SizedBox(width: Spacing.xs), - // Domain or title - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 120), - child: Text( - link.title ?? domain, - style: TextStyle( - fontSize: AppTypography.labelSmall, - color: theme.textSecondary, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: Spacing.xxs), - Icon( - Icons.open_in_new_rounded, - size: 10, - color: theme.textTertiary, - ), - ], - ), - ), - ), - ); - } void _launchUrl(String url) async { try { @@ -681,7 +497,6 @@ List _collectQueries(ChatStatusUpdate update) { List<_LinkData> _collectLinks(ChatStatusUpdate update) { final links = <_LinkData>[]; - // Collect from items for (final item in update.items) { final url = item.link; if (url != null && url.isNotEmpty) { @@ -689,7 +504,6 @@ List<_LinkData> _collectLinks(ChatStatusUpdate update) { } } - // Collect from urls for (final url in update.urls) { if (url.isNotEmpty && !links.any((l) => l.url == url)) { links.add(_LinkData(url: url)); @@ -703,7 +517,6 @@ String _resolveStatusDescription(ChatStatusUpdate update) { final description = update.description?.trim(); final action = update.action?.trim(); - // Match OpenWebUI copy exactly if (action == 'knowledge_search' && update.query?.isNotEmpty == true) { return 'Searching Knowledge for "${update.query}"'; } @@ -723,9 +536,7 @@ String _resolveStatusDescription(ChatStatusUpdate update) { return 'Retrieved $count sources'; } - // Handle description with placeholders if (description != null && description.isNotEmpty) { - // Handle known OpenWebUI descriptions if (description == 'Generating search query') { return 'Generating search query'; } @@ -739,7 +550,6 @@ String _resolveStatusDescription(ChatStatusUpdate update) { } if (action != null && action.isNotEmpty) { - // Convert action to readable text return action.replaceAll('_', ' ').capitalize(); } @@ -773,4 +583,3 @@ extension _StringExtension on String { return '${this[0].toUpperCase()}${substring(1)}'; } } -