blob: 3b7bee85d99c9c39301e263595f38e8afe1b3aab [file]
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),
]),
]),
]);
}
}