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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedCrossFade(
firstChild: CodeBlock(text: widget.text, textColor: widget.textColor),
secondChild: CodeBlock(
text: widget.text,
textColor: widget.textColor,
maxLines: 3,
ClipRect(
child: AnimatedCrossFade(
firstChild: CodeBlock(text: widget.text, textColor: widget.textColor),
secondChild: CodeBlock(
text: widget.text,
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(
onTap: () => setState(() => _expanded = !_expanded),
@ -540,24 +543,27 @@ class _CollapsibleThinkingState extends State<_CollapsibleThinking> {
],
),
),
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,
ClipRect(
child: 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),
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),
// Countdown + action buttons
Row(
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
// Countdown
Icon(
Icons.timer,
size: 14,
color: _isExpired ? AppColors.error : AppColors.textMuted,
),
const SizedBox(width: 4),
Text(
_countdownLabel,
style: TextStyle(
color: _isExpired ? AppColors.error : AppColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.timer,
size: 14,
color: _isExpired ? AppColors.error : AppColors.textMuted,
),
const SizedBox(width: 4),
Text(
_countdownLabel,
style: TextStyle(
color: _isExpired ? AppColors.error : AppColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
const Spacer(),
// Reject button
// Action buttons or status
if (!isAlreadyActioned && !_isExpired)
OutlinedButton(
onPressed: () => _showRejectDialog(context),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
minimumSize: Size.zero,
),
child: const Text('拒绝', style: TextStyle(fontSize: 13)),
),
if (!isAlreadyActioned && !_isExpired) const SizedBox(width: 8),
// Approve button
if (!isAlreadyActioned && !_isExpired)
FilledButton(
onPressed: widget.onApprove,
style: FilledButton.styleFrom(
backgroundColor: AppColors.success,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
minimumSize: Size.zero,
),
child: const Text('通过', style: TextStyle(fontSize: 13)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton(
onPressed: () => _showRejectDialog(context),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
minimumSize: Size.zero,
),
child: const Text('拒绝', style: TextStyle(fontSize: 13)),
),
const SizedBox(width: 8),
FilledButton(
onPressed: widget.onApprove,
style: FilledButton.styleFrom(
backgroundColor: AppColors.success,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
minimumSize: Size.zero,
),
child: const Text('通过', style: TextStyle(fontSize: 13)),
),
],
),
// Status label if already actioned

View File

@ -38,33 +38,29 @@ class TimelineEventNode extends StatelessWidget {
@override
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left column: vertical line + dot
// Left column: dot only (line is drawn by CustomPaint)
SizedBox(
width: 28,
child: Column(
children: [
// Line above dot
Container(
width: 1.5,
height: 8,
color: isFirst ? Colors.transparent : AppColors.surfaceLight,
),
// Dot or spinner
status == NodeStatus.active
child: Padding(
padding: const EdgeInsets.only(top: 6),
child: Center(
child: status == NodeStatus.active
? _SpinnerDot(color: _dotColor)
: _StaticDot(color: _dotColor, icon: icon),
// Line below dot
Expanded(
child: Container(
width: 1.5,
color: isLast ? Colors.transparent : AppColors.surfaceLight,
),
),
],
),
),
),
const SizedBox(width: 8),
@ -74,6 +70,7 @@ class TimelineEventNode extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Label row
Padding(
@ -87,6 +84,8 @@ class TimelineEventNode extends StatelessWidget {
fontSize: 13,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// 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.
class _StaticDot extends StatelessWidget {
final Color color;