From e20936ee2a5d04b9e0e57a7b15ac3d6f8267f641 Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 24 Feb 2026 03:34:58 -0800 Subject: [PATCH] feat: collapsible thinking node in chat timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../chat/presentation/pages/chat_page.dart | 109 +++++++++++++++++- 1 file changed, 103 insertions(+), 6 deletions(-) diff --git a/it0_app/lib/features/chat/presentation/pages/chat_page.dart b/it0_app/lib/features/chat/presentation/pages/chat_page.dart index b4f82de..85921ff 100644 --- a/it0_app/lib/features/chat/presentation/pages/chat_page.dart +++ b/it0_app/lib/features/chat/presentation/pages/chat_page.dart @@ -89,14 +89,9 @@ class _ChatPageState extends ConsumerState { 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 { // 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 draft; final VoidCallback onConfirm;