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,7 +435,8 @@ class _CollapsibleCodeBlockState extends State<_CollapsibleCodeBlock> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AnimatedCrossFade( ClipRect(
child: AnimatedCrossFade(
firstChild: CodeBlock(text: widget.text, textColor: widget.textColor), firstChild: CodeBlock(text: widget.text, textColor: widget.textColor),
secondChild: CodeBlock( secondChild: CodeBlock(
text: widget.text, text: widget.text,
@ -446,6 +447,8 @@ class _CollapsibleCodeBlockState extends State<_CollapsibleCodeBlock> {
? CrossFadeState.showFirst ? CrossFadeState.showFirst
: CrossFadeState.showSecond, : CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
sizeCurve: Curves.easeInOut,
),
), ),
GestureDetector( GestureDetector(
onTap: () => setState(() => _expanded = !_expanded), onTap: () => setState(() => _expanded = !_expanded),
@ -540,7 +543,8 @@ class _CollapsibleThinkingState extends State<_CollapsibleThinking> {
], ],
), ),
), ),
AnimatedCrossFade( ClipRect(
child: AnimatedCrossFade(
firstChild: Padding( firstChild: Padding(
padding: const EdgeInsets.only(top: 6), padding: const EdgeInsets.only(top: 6),
child: StreamTextWidget( child: StreamTextWidget(
@ -558,6 +562,8 @@ class _CollapsibleThinkingState extends State<_CollapsibleThinking> {
? CrossFadeState.showFirst ? CrossFadeState.showFirst
: CrossFadeState.showSecond, : CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
sizeCurve: Curves.easeInOut,
),
), ),
], ],
); );

View File

@ -148,9 +148,16 @@ 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
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon( Icon(
Icons.timer, Icons.timer,
size: 14, size: 14,
@ -165,11 +172,14 @@ class _ApprovalActionCardState extends State<ApprovalActionCard> {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
],
),
const Spacer(), // Action buttons or status
// Reject button
if (!isAlreadyActioned && !_isExpired) if (!isAlreadyActioned && !_isExpired)
Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton( OutlinedButton(
onPressed: () => _showRejectDialog(context), onPressed: () => _showRejectDialog(context),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
@ -180,11 +190,7 @@ class _ApprovalActionCardState extends State<ApprovalActionCard> {
), ),
child: const Text('拒绝', style: TextStyle(fontSize: 13)), child: const Text('拒绝', style: TextStyle(fontSize: 13)),
), ),
const SizedBox(width: 8),
if (!isAlreadyActioned && !_isExpired) const SizedBox(width: 8),
// Approve button
if (!isAlreadyActioned && !_isExpired)
FilledButton( FilledButton(
onPressed: widget.onApprove, onPressed: widget.onApprove,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
@ -194,6 +200,8 @@ class _ApprovalActionCardState extends State<ApprovalActionCard> {
), ),
child: const Text('通过', style: TextStyle(fontSize: 13)), child: const Text('通过', style: TextStyle(fontSize: 13)),
), ),
],
),
// Status label if already actioned // Status label if already actioned
if (isAlreadyActioned) if (isAlreadyActioned)

View File

@ -38,34 +38,30 @@ 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),
// Right column: label + content // Right column: label + content
@ -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;