feat: collapsible thinking node in chat timeline

Thinking content auto-expands while streaming, auto-collapses when done.
User can toggle with "Thinking ∨" button, matching Claude Code VSCode UX.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-24 03:34:58 -08:00
parent 8e4bd573f4
commit e20936ee2a
1 changed files with 103 additions and 6 deletions

View File

@ -89,14 +89,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
label: '思考中...',
isFirst: isFirst,
isLast: isLast,
content: StreamTextWidget(
content: _CollapsibleThinking(
text: message.content,
isStreaming: isStreamingNow,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
fontStyle: FontStyle.italic,
),
),
);
@ -378,6 +373,108 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Standing order content (embedded in timeline node)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Collapsible thinking content expanded while streaming, collapsed when done
// ---------------------------------------------------------------------------
class _CollapsibleThinking extends StatefulWidget {
final String text;
final bool isStreaming;
const _CollapsibleThinking({
required this.text,
required this.isStreaming,
});
@override
State<_CollapsibleThinking> createState() => _CollapsibleThinkingState();
}
class _CollapsibleThinkingState extends State<_CollapsibleThinking> {
bool _expanded = true;
@override
void didUpdateWidget(covariant _CollapsibleThinking oldWidget) {
super.didUpdateWidget(oldWidget);
// Auto-collapse when streaming ends
if (oldWidget.isStreaming && !widget.isStreaming) {
setState(() => _expanded = false);
}
}
@override
Widget build(BuildContext context) {
// While streaming, always show expanded content
if (widget.isStreaming) {
return StreamTextWidget(
text: widget.text,
isStreaming: true,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
fontStyle: FontStyle.italic,
),
);
}
// Completed show collapsible toggle
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Thinking',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 4),
AnimatedRotation(
turns: _expanded ? 0.5 : 0.0,
duration: const Duration(milliseconds: 200),
child: Icon(
Icons.expand_more,
size: 16,
color: AppColors.textMuted,
),
),
],
),
),
AnimatedCrossFade(
firstChild: Padding(
padding: const EdgeInsets.only(top: 6),
child: StreamTextWidget(
text: widget.text,
isStreaming: false,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
fontStyle: FontStyle.italic,
),
),
),
secondChild: const SizedBox.shrink(),
crossFadeState: _expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Standing order content (embedded in timeline node)
// ---------------------------------------------------------------------------
class _StandingOrderContent extends StatelessWidget {
final Map<String, dynamic> draft;
final VoidCallback onConfirm;