fix: resolve bottom overflow issues in chat page timeline rendering

Three root causes fixed:

1. TimelineEventNode: Replaced IntrinsicHeight (which forces intrinsic
   height calculation on unbounded content) with CustomPaint-based
   _TimelineLinePainter that draws vertical lines based on actual
   rendered widget size. Also added maxLines/ellipsis to label text
   and mainAxisSize.min on inner Column.

2. ApprovalActionCard: Changed countdown + action buttons layout from
   Row with Spacer (which requires infinite width) to Wrap with
   spacing, preventing horizontal overflow on narrow screens.

3. AnimatedCrossFade in _CollapsibleCodeBlock and _CollapsibleThinking:
   Wrapped with ClipRect and added sizeCurve: Curves.easeInOut to
   prevent the outgoing child from extending beyond parent bounds
   during the cross-fade transition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-28 01:38:37 -08:00
parent 50dbb641a3
commit 89f0f6134d
3 changed files with 143 additions and 85 deletions

View File

@ -435,17 +435,20 @@ class _CollapsibleCodeBlockState extends State<_CollapsibleCodeBlock> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AnimatedCrossFade( ClipRect(
firstChild: CodeBlock(text: widget.text, textColor: widget.textColor), child: AnimatedCrossFade(
secondChild: CodeBlock( firstChild: CodeBlock(text: widget.text, textColor: widget.textColor),
text: widget.text, secondChild: CodeBlock(
textColor: widget.textColor, text: widget.text,
maxLines: 3, textColor: widget.textColor,
maxLines: 3,
),
crossFadeState: _expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
sizeCurve: Curves.easeInOut,
), ),
crossFadeState: _expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
), ),
GestureDetector( GestureDetector(
onTap: () => setState(() => _expanded = !_expanded), onTap: () => setState(() => _expanded = !_expanded),
@ -540,24 +543,27 @@ class _CollapsibleThinkingState extends State<_CollapsibleThinking> {
], ],
), ),
), ),
AnimatedCrossFade( ClipRect(
firstChild: Padding( child: AnimatedCrossFade(
padding: const EdgeInsets.only(top: 6), firstChild: Padding(
child: StreamTextWidget( padding: const EdgeInsets.only(top: 6),
text: widget.text, child: StreamTextWidget(
isStreaming: false, text: widget.text,
style: const TextStyle( isStreaming: false,
color: AppColors.textSecondary, style: const TextStyle(
fontSize: 13, color: AppColors.textSecondary,
fontStyle: FontStyle.italic, fontSize: 13,
fontStyle: FontStyle.italic,
),
), ),
), ),
secondChild: const SizedBox.shrink(),
crossFadeState: _expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
sizeCurve: Curves.easeInOut,
), ),
secondChild: const SizedBox.shrink(),
crossFadeState: _expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
), ),
], ],
); );

View File

@ -148,51 +148,59 @@ class _ApprovalActionCardState extends State<ApprovalActionCard> {
const SizedBox(height: 10), const SizedBox(height: 10),
// Countdown + action buttons // Countdown + action buttons
Row( Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: [ children: [
// Countdown // Countdown
Icon( Row(
Icons.timer, mainAxisSize: MainAxisSize.min,
size: 14, children: [
color: _isExpired ? AppColors.error : AppColors.textMuted, Icon(
), Icons.timer,
const SizedBox(width: 4), size: 14,
Text( color: _isExpired ? AppColors.error : AppColors.textMuted,
_countdownLabel, ),
style: TextStyle( const SizedBox(width: 4),
color: _isExpired ? AppColors.error : AppColors.textSecondary, Text(
fontSize: 12, _countdownLabel,
fontWeight: FontWeight.w500, style: TextStyle(
), color: _isExpired ? AppColors.error : AppColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
), ),
const Spacer(), // Action buttons or status
// Reject button
if (!isAlreadyActioned && !_isExpired) if (!isAlreadyActioned && !_isExpired)
OutlinedButton( Row(
onPressed: () => _showRejectDialog(context), mainAxisSize: MainAxisSize.min,
style: OutlinedButton.styleFrom( children: [
foregroundColor: AppColors.error, OutlinedButton(
side: const BorderSide(color: AppColors.error), onPressed: () => _showRejectDialog(context),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), style: OutlinedButton.styleFrom(
minimumSize: Size.zero, foregroundColor: AppColors.error,
), side: const BorderSide(color: AppColors.error),
child: const Text('拒绝', style: TextStyle(fontSize: 13)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
), minimumSize: Size.zero,
),
if (!isAlreadyActioned && !_isExpired) const SizedBox(width: 8), child: const Text('拒绝', style: TextStyle(fontSize: 13)),
),
// Approve button const SizedBox(width: 8),
if (!isAlreadyActioned && !_isExpired) FilledButton(
FilledButton( onPressed: widget.onApprove,
onPressed: widget.onApprove, style: FilledButton.styleFrom(
style: FilledButton.styleFrom( backgroundColor: AppColors.success,
backgroundColor: AppColors.success, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), minimumSize: Size.zero,
minimumSize: Size.zero, ),
), child: const Text('通过', style: TextStyle(fontSize: 13)),
child: const Text('通过', style: TextStyle(fontSize: 13)), ),
],
), ),
// Status label if already actioned // Status label if already actioned

View File

@ -38,33 +38,29 @@ class TimelineEventNode extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IntrinsicHeight( // Use CustomPaint for the timeline line to avoid IntrinsicHeight which
// causes bottom-overflow on long content.
return CustomPaint(
painter: _TimelineLinePainter(
color: AppColors.surfaceLight,
isFirst: isFirst,
isLast: isLast,
dotOffset: 14, // vertical center of the dot area
lineX: 14, // horizontal center of the 28px left column
),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Left column: vertical line + dot // Left column: dot only (line is drawn by CustomPaint)
SizedBox( SizedBox(
width: 28, width: 28,
child: Column( child: Padding(
children: [ padding: const EdgeInsets.only(top: 6),
// Line above dot child: Center(
Container( child: status == NodeStatus.active
width: 1.5,
height: 8,
color: isFirst ? Colors.transparent : AppColors.surfaceLight,
),
// Dot or spinner
status == NodeStatus.active
? _SpinnerDot(color: _dotColor) ? _SpinnerDot(color: _dotColor)
: _StaticDot(color: _dotColor, icon: icon), : _StaticDot(color: _dotColor, icon: icon),
// Line below dot ),
Expanded(
child: Container(
width: 1.5,
color: isLast ? Colors.transparent : AppColors.surfaceLight,
),
),
],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -74,6 +70,7 @@ class TimelineEventNode extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
// Label row // Label row
Padding( Padding(
@ -87,6 +84,8 @@ class TimelineEventNode extends StatelessWidget {
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
), ),
// Content // Content
@ -104,6 +103,51 @@ class TimelineEventNode extends StatelessWidget {
} }
} }
/// Paints the vertical timeline line behind the node content.
/// Avoids IntrinsicHeight by painting lines based on the actual widget size.
class _TimelineLinePainter extends CustomPainter {
final Color color;
final bool isFirst;
final bool isLast;
final double dotOffset;
final double lineX;
_TimelineLinePainter({
required this.color,
required this.isFirst,
required this.isLast,
required this.dotOffset,
required this.lineX,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 1.5;
// Line above the dot
if (!isFirst) {
canvas.drawLine(Offset(lineX, 0), Offset(lineX, dotOffset), paint);
}
// Line below the dot
if (!isLast) {
canvas.drawLine(
Offset(lineX, dotOffset + 10),
Offset(lineX, size.height),
paint,
);
}
}
@override
bool shouldRepaint(_TimelineLinePainter oldDelegate) =>
color != oldDelegate.color ||
isFirst != oldDelegate.isFirst ||
isLast != oldDelegate.isLast;
}
/// Static colored dot with optional icon for completed/error/idle states. /// Static colored dot with optional icon for completed/error/idle states.
class _StaticDot extends StatelessWidget { class _StaticDot extends StatelessWidget {
final Color color; final Color color;