mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-06 14:23:19 +08:00
feat: 优化 AI Agent 卡片展示&溢出滚动
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user