feat: 优化 AI Agent 卡片展示&溢出滚动

This commit is contained in:
chaoszhu
2026-08-29 16:00:03 +08:00
parent 95354ff0ae
commit a67c9f7e79
14 changed files with 618 additions and 152 deletions
@@ -61,55 +61,71 @@ class _AgentApprovalCardState extends ConsumerState<AgentApprovalCard> {
],
),
const SizedBox(height: 10),
Text(
l.trf('agent.approval.description', [
item.hostName ?? '-',
_toolLabel(l, item.tool),
]),
ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: AgentUiTokens.messagePartContentMaxHeight,
),
child: SingleChildScrollView(
key: const Key('agent-approval-details-scroll'),
primary: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l.trf('agent.approval.description', [
item.hostName ?? '-',
_toolLabel(l, item.tool),
]),
),
if (item.effect != null) ...[
const SizedBox(height: 6),
Text(
l.trf('agent.approval.scope', [
l.tr('agent.effect.${item.effect}'),
]),
style: TextStyle(color: context.colors.muted),
),
],
if (item.targets.isNotEmpty)
_DetailBlock(
label: l.tr('agent.approval.targets'),
text: item.targets.join('\n'),
),
if (item.sensitiveDisclosure) ...[
const SizedBox(height: 8),
Text(
l.tr('agent.approval.sensitive'),
style: TextStyle(
color: context.colors.danger,
fontWeight: FontWeight.w700,
),
),
],
if (preview?['diff'] != null)
_DetailBlock(
label: l.tr('agent.approval.diff'),
text: preview!['diff'].toString(),
copyable: true,
)
else
_DetailBlock(
label: l.tr('agent.arguments'),
text: const JsonEncoder.withIndent(
' ',
).convert(item.input),
),
if (item.risk?['reason'] != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
item.risk!['reason'].toString(),
style: TextStyle(color: context.colors.danger),
),
),
],
),
),
),
if (item.effect != null) ...[
const SizedBox(height: 6),
Text(
l.trf('agent.approval.scope', [
l.tr('agent.effect.${item.effect}'),
]),
style: TextStyle(color: context.colors.muted),
),
],
if (item.targets.isNotEmpty)
_DetailBlock(
label: l.tr('agent.approval.targets'),
text: item.targets.join('\n'),
),
if (item.sensitiveDisclosure) ...[
const SizedBox(height: 8),
Text(
l.tr('agent.approval.sensitive'),
style: TextStyle(
color: context.colors.danger,
fontWeight: FontWeight.w700,
),
),
],
if (preview?['diff'] != null)
_DetailBlock(
label: l.tr('agent.approval.diff'),
text: preview!['diff'].toString(),
copyable: true,
)
else
_DetailBlock(
label: l.tr('agent.arguments'),
text: const JsonEncoder.withIndent(' ').convert(item.input),
),
if (item.risk?['reason'] != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
item.risk!['reason'].toString(),
style: TextStyle(color: context.colors.danger),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
@@ -634,13 +634,22 @@ class _ReasoningBlockState extends State<_ReasoningBlock> {
child: expanded
? Padding(
padding: const EdgeInsets.fromLTRB(12, 2, 12, 12),
child: SelectableText(
widget.part.text,
key: const Key('agent-reasoning-content'),
style: TextStyle(
color: colors.muted,
fontSize: 13,
height: 1.55,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: AgentUiTokens.messagePartContentMaxHeight,
),
child: SingleChildScrollView(
key: const Key('agent-reasoning-content-scroll'),
primary: false,
child: SelectableText(
widget.part.text,
key: const Key('agent-reasoning-content'),
style: TextStyle(
color: colors.muted,
fontSize: 13,
height: 1.55,
),
),
),
),
)
+158 -60
View File
@@ -18,6 +18,7 @@ import '../settings/ai_agent_settings_page.dart';
import 'agent_controller.dart';
import 'agent_feedback.dart';
import 'agent_models.dart';
import 'agent_reducer.dart';
import 'agent_selection_sheet.dart';
import 'agent_socket_client.dart';
import 'agent_ui_tokens.dart';
@@ -45,16 +46,21 @@ class AgentPanel extends ConsumerStatefulWidget {
class _AgentPanelState extends ConsumerState<AgentPanel> {
final _draft = TextEditingController();
final _scroll = ScrollController();
var _isAtBottom = true;
var _autoFollow = true;
var _scrollingToBottom = false;
@override
void initState() {
super.initState();
_scroll.addListener(_handleScroll);
Future.microtask(() => ref.read(agentControllerProvider.notifier).open());
}
@override
void dispose() {
_draft.dispose();
_scroll.removeListener(_handleScroll);
_scroll.dispose();
super.dispose();
}
@@ -64,20 +70,18 @@ class _AgentPanelState extends ConsumerState<AgentPanel> {
final state = ref.watch(agentControllerProvider);
final l = AppLocalizations.of(context);
ref.listen(agentControllerProvider, (previous, next) {
final oldParts =
previous?.conversation.messages.fold<int>(
0,
(sum, item) => sum + item.parts.length,
) ??
0;
final newParts = next.conversation.messages.fold<int>(
0,
(sum, item) => sum + item.parts.length,
);
if (previous?.conversation.messages.length !=
next.conversation.messages.length ||
oldParts != newParts) {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom());
final oldConversation = previous?.conversation;
final conversation = next.conversation;
final contentChanged =
oldConversation == null ||
!identical(oldConversation.messages, conversation.messages) ||
!identical(
oldConversation.pendingApprovals,
conversation.pendingApprovals,
) ||
oldConversation.error != conversation.error;
if (contentChanged && _autoFollow) {
_scrollToBottom();
}
});
@@ -109,49 +113,91 @@ class _AgentPanelState extends ConsumerState<AgentPanel> {
onClose: ref.read(agentControllerProvider.notifier).dismissNotices,
),
Expanded(
child: state.conversation.messages.isEmpty
? _AgentEmptyState(
connected: state.connected,
connecting:
state.connection == AgentConnectionStatus.connecting,
onRetry: _reconnect,
)
: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.fromLTRB(16, 18, 16, 12),
itemCount:
state.conversation.messages.length +
state.conversation.pendingApprovals.length +
(state.conversation.error == null ? 0 : 1),
itemBuilder: (context, index) {
if (index < state.conversation.messages.length) {
final message = state.conversation.messages[index];
return AgentMessageView(
message: message,
running: state.conversation.running,
waitingForModel:
state.conversation.waitingForModel &&
index == state.conversation.messages.length - 1,
);
}
final approvalIndex =
index - state.conversation.messages.length;
if (approvalIndex <
state.conversation.pendingApprovals.length) {
return AgentApprovalCard(
approval:
state.conversation.pendingApprovals[approvalIndex],
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
state.conversation.error!,
style: TextStyle(color: context.colors.danger),
child: Stack(
children: [
Positioned.fill(
child: state.conversation.messages.isEmpty
? _AgentEmptyState(
connected: state.connected,
connecting:
state.connection ==
AgentConnectionStatus.connecting,
onRetry: _reconnect,
)
: ListView.builder(
controller: _scroll,
reverse: true,
padding: const EdgeInsets.fromLTRB(16, 18, 16, 12),
itemCount:
state.conversation.messages.length +
state.conversation.pendingApprovals.length +
(state.conversation.error == null ? 0 : 1),
findChildIndexCallback: (key) =>
_conversationChildIndex(key, state.conversation),
itemBuilder: (context, index) {
final itemCount =
state.conversation.messages.length +
state.conversation.pendingApprovals.length +
(state.conversation.error == null ? 0 : 1);
final logicalIndex = itemCount - index - 1;
if (logicalIndex <
state.conversation.messages.length) {
final message =
state.conversation.messages[logicalIndex];
return AgentMessageView(
key: ValueKey('agent-message-${message.id}'),
message: message,
running: state.conversation.running,
waitingForModel:
state.conversation.waitingForModel &&
logicalIndex ==
state.conversation.messages.length - 1,
);
}
final approvalIndex =
logicalIndex - state.conversation.messages.length;
if (approvalIndex <
state.conversation.pendingApprovals.length) {
return AgentApprovalCard(
key: ValueKey(
'agent-approval-${state.conversation.pendingApprovals[approvalIndex].requestId}',
),
approval: state
.conversation
.pendingApprovals[approvalIndex],
);
}
return Padding(
key: const ValueKey('agent-conversation-error'),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
state.conversation.error!,
style: TextStyle(color: context.colors.danger),
),
);
},
),
);
},
),
if (!_isAtBottom && state.conversation.messages.isNotEmpty)
Positioned(
right: 16,
bottom: 12,
child: FloatingActionButton.small(
key: const Key('agent-scroll-to-bottom'),
heroTag: null,
tooltip: l.tr('agent.scrollToBottom'),
elevation: 3,
backgroundColor: context.colors.card,
foregroundColor: context.colors.primary,
shape: CircleBorder(
side: BorderSide(color: context.colors.border),
),
onPressed: _scrollToBottom,
child: const Icon(Icons.keyboard_arrow_down_rounded),
),
),
],
),
),
_AgentComposer(controller: _draft, state: state, onSend: _send),
],
@@ -246,12 +292,36 @@ class _AgentPanelState extends ConsumerState<AgentPanel> {
}
void _scrollToBottom() {
if (!_scroll.hasClients) return;
_scroll.animateTo(
_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
_autoFollow = true;
if (!_isAtBottom && mounted) setState(() => _isAtBottom = true);
if (_scrollingToBottom) return;
_scrollingToBottom = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scroll.hasClients) {
_scrollingToBottom = false;
return;
}
final position = _scroll.position;
if (position.pixels != position.minScrollExtent) {
_scroll.jumpTo(position.minScrollExtent);
}
_scrollingToBottom = false;
_handleScroll();
});
}
void _handleScroll() {
if (_scrollingToBottom || !_scroll.hasClients) return;
final position = _scroll.position;
final distanceFromBottom = math.max(
0.0,
position.pixels - position.minScrollExtent,
);
final isAtBottom = distanceFromBottom < AgentUiTokens.scrollBottomThreshold;
_autoFollow = isAtBottom;
if (isAtBottom != _isAtBottom && mounted) {
setState(() => _isAtBottom = isAtBottom);
}
}
Future<void> _showHistory(BuildContext context) async {
@@ -289,6 +359,34 @@ class _AgentPanelState extends ConsumerState<AgentPanel> {
}
}
int? _conversationChildIndex(Key key, AgentConversationState conversation) {
if (key is! ValueKey<String>) return null;
final value = key.value;
final itemCount =
conversation.messages.length +
conversation.pendingApprovals.length +
(conversation.error == null ? 0 : 1);
int logicalIndex;
if (value.startsWith('agent-message-')) {
final id = value.substring('agent-message-'.length);
logicalIndex = conversation.messages.indexWhere((item) => item.id == id);
} else if (value.startsWith('agent-approval-')) {
final id = value.substring('agent-approval-'.length);
final approvalIndex = conversation.pendingApprovals.indexWhere(
(item) => item.requestId == id,
);
logicalIndex = approvalIndex < 0
? -1
: conversation.messages.length + approvalIndex;
} else if (value == 'agent-conversation-error' &&
conversation.error != null) {
logicalIndex = itemCount - 1;
} else {
return null;
}
return logicalIndex < 0 ? null : itemCount - logicalIndex - 1;
}
String _noticeText(AppLocalizations l, String notice) {
final parts = notice.split(':');
return switch (parts.first) {
@@ -173,39 +173,50 @@ class _AgentToolCardState extends State<AgentToolCard> {
? Padding(
key: ValueKey('agent-tool-details-${part.toolCallId}'),
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (part.risk?['reason'] != null)
_DetailBlock(
label:
part.risk?['category']?.toString() ??
l.tr('agent.risk'),
text: part.risk!['reason'].toString(),
danger: true,
),
if (part.input.isNotEmpty)
_DetailBlock(
label: l.tr('agent.arguments'),
text: const JsonEncoder.withIndent(
' ',
).convert(part.input),
),
if (part.error != null)
_DetailBlock(
label: l.tr('common.error'),
text: part.error == 'tool_incomplete'
? l.tr('agent.toolIncomplete')
: part.error!,
danger: true,
)
else if (part.output != null)
_DetailBlock(
label: l.tr('agent.result'),
text: _stringifyOutput(part.output),
copyable: true,
),
],
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: AgentUiTokens.messagePartContentMaxHeight,
),
child: SingleChildScrollView(
key: ValueKey(
'agent-tool-details-scroll-${part.toolCallId}',
),
primary: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (part.risk?['reason'] != null)
_DetailBlock(
label:
part.risk?['category']?.toString() ??
l.tr('agent.risk'),
text: part.risk!['reason'].toString(),
danger: true,
),
if (part.input.isNotEmpty)
_DetailBlock(
label: l.tr('agent.arguments'),
text: const JsonEncoder.withIndent(
' ',
).convert(part.input),
),
if (part.error != null)
_DetailBlock(
label: l.tr('common.error'),
text: part.error == 'tool_incomplete'
? l.tr('agent.toolIncomplete')
: part.error!,
danger: true,
)
else if (part.output != null)
_DetailBlock(
label: l.tr('agent.result'),
text: _stringifyOutput(part.output),
copyable: true,
),
],
),
),
),
)
: const SizedBox(width: double.infinity),
@@ -12,7 +12,9 @@ abstract final class AgentUiTokens {
static const radiusMedium = 14.0;
static const radiusLarge = 20.0;
static const messagePartHeaderMinHeight = 56.0;
static const messagePartContentMaxHeight = 320.0;
static const messagePartGap = 10.0;
static const scrollBottomThreshold = 40.0;
static const messageActionWidth = 34.0;
static const messageActionHeight = 40.0;
static const messageActionGap = 0.0;
+1
View File
@@ -680,6 +680,7 @@ const Map<String, String> stringsEn = {
'agent.mode.authorizedDescription':
'Run ordinary operations automatically within the host policy.',
'agent.thinking': 'Thinking…',
'agent.scrollToBottom': 'Scroll to bottom',
'agent.analyzingTools': 'Analyzing tool results…',
'agent.reasoning': 'Reasoning',
'agent.regenerate': 'Regenerate',
+1
View File
@@ -645,6 +645,7 @@ const Map<String, String> stringsZh = {
'agent.mode.assistDescription': '明确的只读操作自动执行,写入操作仍需确认。',
'agent.mode.authorizedDescription': '在主机策略范围内自动执行常规操作。',
'agent.thinking': '正在思考…',
'agent.scrollToBottom': '滚动到底部',
'agent.analyzingTools': '正在分析工具结果…',
'agent.reasoning': '推理过程',
'agent.regenerate': '重新生成',
@@ -0,0 +1,59 @@
import 'package:easynode_native/core/ui/app_color_theme.dart';
import 'package:easynode_native/features/ai_agent/agent_models.dart';
import 'package:easynode_native/features/ai_agent/agent_panel.dart';
import 'package:easynode_native/features/ai_agent/agent_ui_tokens.dart';
import 'package:easynode_native/l10n/app_localizations.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets(
'approval details are height limited while actions stay visible',
(tester) async {
final approval = AgentApproval(
requestId: 'approval-1',
toolCallId: 'tool-1',
tool: 'write_file',
input: const {'path': '/tmp/example'},
createdAt: DateTime.now().millisecondsSinceEpoch,
preview: {
'diff': List.generate(100, (index) => 'Diff line $index').join('\n'),
},
);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
theme: ThemeData(
useMaterial3: true,
extensions: const [AppColorTheme.defaultLight],
),
home: Scaffold(body: AgentApprovalCard(approval: approval)),
),
),
);
await tester.pump();
final scroll = find.byKey(const Key('agent-approval-details-scroll'));
expect(
tester.getSize(scroll).height,
AgentUiTokens.messagePartContentMaxHeight,
);
expect(tester.widget<SingleChildScrollView>(scroll).primary, isFalse);
expect(find.widgetWithText(FilledButton, 'Allow'), findsOneWidget);
expect(find.widgetWithText(TextButton, 'Deny'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
},
);
}
@@ -93,6 +93,57 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('long reasoning content is height limited and scrollable', (
tester,
) async {
final message = AgentMessage(
id: 'assistant-long-reasoning',
role: AgentMessageRole.assistant,
parts: [
AgentReasoningPart(
List.generate(100, (index) => 'Reasoning line $index').join('\n'),
),
],
createdAt: 1,
);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
theme: ThemeData(
useMaterial3: true,
extensions: const [AppColorTheme.defaultLight],
),
home: Scaffold(
body: AgentMessageView(
message: message,
running: true,
waitingForModel: false,
),
),
),
),
);
await tester.pump();
await tester.tap(find.byKey(const Key('agent-reasoning-toggle')));
await tester.pumpAndSettle();
final scroll = find.byKey(const Key('agent-reasoning-content-scroll'));
expect(
tester.getSize(scroll).height,
AgentUiTokens.messagePartContentMaxHeight,
);
expect(tester.widget<SingleChildScrollView>(scroll).primary, isFalse);
expect(tester.takeException(), isNull);
});
testWidgets('collapsed message cards share height and outer spacing', (
tester,
) async {
@@ -0,0 +1,165 @@
import 'package:easynode_native/core/api/api_client.dart';
import 'package:easynode_native/core/api/cookie_store.dart';
import 'package:easynode_native/core/security/server_certificate_trust.dart';
import 'package:easynode_native/core/storage/app_storage.dart';
import 'package:easynode_native/core/storage/secure_storage.dart';
import 'package:easynode_native/core/ui/app_color_theme.dart';
import 'package:easynode_native/features/ai_agent/agent_models.dart';
import 'package:easynode_native/features/ai_agent/agent_panel.dart';
import 'package:easynode_native/features/ai_agent/agent_reducer.dart';
import 'package:easynode_native/features/ai_agent/agent_repository.dart';
import 'package:easynode_native/features/ai_agent/agent_socket_client.dart';
import 'package:easynode_native/features/auth/auth_session.dart';
import 'package:easynode_native/l10n/app_localizations.dart';
import 'package:easynode_native/state/agent_providers.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('long conversation offers one-tap scrolling to the bottom', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
final storage = AppStorage(await SharedPreferences.getInstance());
final notifier = _FakeAgentStateNotifier(_TestAgentDeps(storage));
await tester.pumpWidget(
ProviderScope(
overrides: [agentControllerProvider.overrideWith((ref) => notifier)],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
theme: ThemeData(
useMaterial3: true,
extensions: const [AppColorTheme.defaultLight],
),
home: const Scaffold(body: AgentPanel(showHeader: false)),
),
),
);
await tester.pump();
final messages = List.generate(
30,
(index) => AgentMessage(
id: 'assistant-$index',
role: AgentMessageRole.assistant,
parts: [
AgentTextPart(
List.generate(
index % 7 + 1,
(line) => 'Response $index, variable-height line $line',
).join('\n'),
),
],
createdAt: index,
),
);
notifier.showConversation(AgentConversationState(messages: messages));
await tester.pumpAndSettle();
const buttonKey = Key('agent-scroll-to-bottom');
final list = tester.widget<ListView>(find.byType(ListView));
expect(list.reverse, isTrue);
expect(find.byKey(buttonKey), findsNothing);
expect(
find.byKey(const ValueKey('agent-message-assistant-29')),
findsOneWidget,
);
expect(
list.controller!.position.pixels,
list.controller!.position.minScrollExtent,
);
await tester.drag(find.byType(ListView), const Offset(0, 320));
await tester.pumpAndSettle();
expect(find.byKey(buttonKey), findsOneWidget);
expect(find.byTooltip('Scroll to bottom'), findsOneWidget);
expect(
list.controller!.position.pixels -
list.controller!.position.minScrollExtent,
greaterThan(40),
);
await tester.tap(find.byKey(buttonKey));
notifier.showConversation(
AgentConversationState(
messages: [
...messages,
const AgentMessage(
id: 'assistant-stream-update',
role: AgentMessageRole.assistant,
parts: [AgentTextPart('A new streamed response')],
createdAt: 31,
),
],
),
);
await tester.pump();
expect(find.byKey(buttonKey), findsNothing);
expect(
list.controller!.position.pixels,
list.controller!.position.minScrollExtent,
);
await tester.pump(const Duration(milliseconds: 500));
expect(
list.controller!.position.pixels,
list.controller!.position.minScrollExtent,
);
expect(tester.takeException(), isNull);
});
}
class _FakeAgentStateNotifier extends AgentStateNotifier {
_FakeAgentStateNotifier(_TestAgentDeps deps)
: super(
repository: deps.repository,
socket: deps.socket,
storage: deps.storage,
);
@override
Future<void> open() async {}
void showConversation(AgentConversationState conversation) {
state = state.copyWith(conversation: conversation);
}
}
class _TestAgentDeps {
_TestAgentDeps(this.storage) {
final secureStorage = SecureAppStorage(const FlutterSecureStorage());
final cookieStore = SessionCookieStore(secureStorage);
repository = AgentRepository(
ApiClient(serverAddress: 'http://localhost', cookieStore: cookieStore),
);
socket = AgentSocketClient(
authSession: const AuthSession(
serverAddress: 'http://localhost',
username: 'tester',
token: 'token',
deviceId: 'device',
),
cookieStore: cookieStore,
certificateTrust: ServerCertificateTrustStore(secureStorage),
);
}
final AppStorage storage;
late final AgentRepository repository;
late final AgentSocketClient socket;
}
@@ -1,6 +1,7 @@
import 'package:easynode_native/core/ui/app_color_theme.dart';
import 'package:easynode_native/features/ai_agent/agent_models.dart';
import 'package:easynode_native/features/ai_agent/agent_panel.dart';
import 'package:easynode_native/features/ai_agent/agent_ui_tokens.dart';
import 'package:easynode_native/l10n/app_localizations.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
@@ -49,4 +50,51 @@ void main() {
expect(find.text('all good'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('long tool details are height limited and scrollable', (
tester,
) async {
final output = List.generate(
100,
(index) => 'Output line $index',
).join('\n');
final part = AgentToolPart(
toolCallId: 'long-tool',
tool: 'exec_command',
input: const {'command': 'generate-long-output'},
status: AgentToolStatus.done,
output: {'stdout': output},
);
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
theme: ThemeData(
useMaterial3: true,
extensions: const [AppColorTheme.defaultLight],
),
home: Scaffold(body: AgentToolCard(part: part)),
),
);
await tester.pump();
await tester.tap(find.byKey(const ValueKey('agent-tool-toggle-long-tool')));
await tester.pumpAndSettle();
final scroll = find.byKey(
const ValueKey('agent-tool-details-scroll-long-tool'),
);
expect(
tester.getSize(scroll).height,
AgentUiTokens.messagePartContentMaxHeight,
);
expect(tester.widget<SingleChildScrollView>(scroll).primary, isFalse);
expect(tester.takeException(), isNull);
});
}