| import 'dart:async'; |
| import 'package:jaspr/dom.dart'; |
| import 'package:jaspr/jaspr.dart'; |
| import 'package:jaspr_content/components/markdown.dart'; |
| |
| import '../../../utils/styles.dart'; |
| |
| /// A widget that displays a "thinking" indicator with elapsed time, |
| /// optional expand/collapse, and a scrollable content area. |
| class AgentThinkingBubble extends StatefulComponent { |
| const AgentThinkingBubble({required this.message, this.duration, this.collapsible = false, super.key}); |
| |
| final String message; |
| final Duration? duration; |
| final bool collapsible; |
| |
| @override |
| State<AgentThinkingBubble> createState() => _AgentThinkingBubbleState(); |
| |
| @css |
| static List<StyleRule> get styles => [ |
| css('.agent-thinking-wrapper').styles( |
| display: .flex, |
| width: 100.percent, |
| margin: Margin.zero, |
| flexDirection: .column, |
| alignItems: .start, |
| fontFamily: const .list([FontFamily('Inter'), FontFamilies.sansSerif]), |
| ), |
| css('.agent-thinking-header').styles( |
| display: .flex, |
| padding: Padding.symmetric(vertical: 4.px), |
| alignItems: .center, |
| color: colorOnSurfaceVariant, |
| fontSize: 12.px, |
| fontWeight: .w500, |
| raw: {'user-select': 'none'}, |
| ), |
| css('.agent-thinking-header.collapsible').styles( |
| cursor: .pointer, |
| ), |
| css('.agent-thinking-header.collapsible:hover').styles( |
| color: colorOnSurface, |
| ), |
| css('.agent-thinking-chevron').styles( |
| margin: Margin.only(left: 6.px), |
| color: colorOnSurfaceVariant, |
| fontSize: 15.px, |
| ), |
| css('.agent-thinking-scrollable').styles( |
| display: .flex, |
| width: 100.percent, |
| maxHeight: 300.px, |
| padding: Padding.symmetric(vertical: 4.px, horizontal: 0.px), |
| margin: Margin.only(top: 2.px, bottom: 2.px), |
| boxSizing: .borderBox, |
| overflow: const .only(y: .auto), |
| flexDirection: .column, |
| ), |
| css('.agent-thinking-scrollable::-webkit-scrollbar').styles( |
| width: 6.px, |
| ), |
| css('.agent-thinking-scrollable::-webkit-scrollbar-track').styles( |
| backgroundColor: Colors.transparent, |
| ), |
| css('.agent-thinking-scrollable::-webkit-scrollbar-thumb').styles( |
| radius: .circular(3.px), |
| backgroundColor: colorBorder, |
| ), |
| css('.agent-thinking-scrollable::-webkit-scrollbar-thumb:hover').styles( |
| backgroundColor: colorOnSurfaceVariant, |
| ), |
| css('.agent-thinking-text').styles( |
| color: colorOnSurfaceVariant, |
| fontSize: 12.px, |
| lineHeight: 16.px, |
| raw: { |
| 'white-space': 'pre-wrap', |
| 'word-break': 'break-word', |
| }, |
| ), |
| css('.agent-thinking-text p').styles( |
| margin: Margin.zero, |
| ), |
| css('.agent-thinking-text p + p').styles( |
| margin: Margin.only(top: 4.px), |
| ), |
| css('.agent-thinking-text ul, .agent-thinking-text ol').styles( |
| padding: Padding.zero, |
| margin: Margin.only(top: 4.px, bottom: 4.px, left: 14.px), |
| ), |
| ]; |
| } |
| |
| class _AgentThinkingBubbleState extends State<AgentThinkingBubble> { |
| Timer? _timer; |
| int _elapsedSeconds = 0; |
| DateTime? _startTime; |
| bool _isExpanded = false; |
| |
| @override |
| void initState() { |
| super.initState(); |
| _startTimerIfNeeded(); |
| } |
| |
| @override |
| void didUpdateComponent(covariant AgentThinkingBubble oldComponent) { |
| super.didUpdateComponent(oldComponent); |
| if (component.duration != null) { |
| _timer?.cancel(); |
| _timer = null; |
| } else if (_timer == null) { |
| _startTimerIfNeeded(); |
| } |
| } |
| |
| void _startTimerIfNeeded() { |
| if (component.duration == null) { |
| _startTime = DateTime.now(); |
| _elapsedSeconds = 0; |
| _timer = Timer.periodic(const Duration(seconds: 1), (timer) { |
| if (mounted) { |
| setState(() { |
| _elapsedSeconds = DateTime.now().difference(_startTime!).inSeconds; |
| }); |
| } |
| }); |
| } |
| } |
| |
| @override |
| void dispose() { |
| _timer?.cancel(); |
| super.dispose(); |
| } |
| |
| @override |
| Component build(BuildContext context) { |
| final message = component.message; |
| final duration = component.duration; |
| final bool isPending = duration == null; |
| final int seconds = duration?.inSeconds ?? _elapsedSeconds; |
| final String headerText; |
| if (isPending && message.isNotEmpty) { |
| headerText = message; |
| } else if (isPending) { |
| headerText = 'Thinking…${seconds > 0 ? ' ${seconds}s' : ''}'; |
| } else { |
| headerText = 'Thought for ${seconds}s'; |
| } |
| final showDetails = !isPending && message.isNotEmpty && (!component.collapsible || _isExpanded); |
| |
| return div(classes: 'agent-thinking-wrapper', [ |
| div( |
| classes: 'agent-thinking-header${component.collapsible ? ' collapsible' : ''}', |
| events: component.collapsible |
| ? { |
| 'click': (e) { |
| setState(() { |
| _isExpanded = !_isExpanded; |
| }); |
| }, |
| } |
| : null, |
| [ |
| span(classes: 'agent-thinking-title', [Component.text(headerText)]), |
| if (component.collapsible && !isPending) |
| span(classes: 'agent-thinking-chevron', [Component.text(_isExpanded ? ' ▾' : ' ▸')]), |
| ], |
| ), |
| if (showDetails) |
| div(classes: 'agent-thinking-scrollable', [ |
| div(classes: 'agent-thinking-text', [ |
| Markdown(content: message), |
| ]), |
| ]), |
| ]); |
| } |
| } |