Fix task refresh layout stability
This commit is contained in:
parent
71bae611fc
commit
e7a7ba92c4
@ -179,7 +179,7 @@ class AssistantPageStateInternal extends State<AssistantPage> {
|
||||
);
|
||||
final scrollSignature = messages.isEmpty
|
||||
? controller.currentSessionKey
|
||||
: '${controller.currentSessionKey}:${messages.length}:${messages.last.id}:${messages.last.pending}:${messages.last.error}';
|
||||
: '${controller.currentSessionKey}:${messages.length}:${messages.last.id}';
|
||||
|
||||
if (scrollSignature != lastConversationScrollSignatureInternal) {
|
||||
lastConversationScrollSignatureInternal = scrollSignature;
|
||||
@ -751,59 +751,66 @@ class ConversationAreaInternal extends StatelessWidget {
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 6),
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return switch (item.kind) {
|
||||
TimelineItemKindInternal.user => MessageBubbleInternal(
|
||||
label: item.label!,
|
||||
text: item.text!,
|
||||
alignRight: true,
|
||||
tone: BubbleToneInternal.user,
|
||||
messageViewMode: messageViewMode,
|
||||
return KeyedSubtree(
|
||||
key: ValueKey<String>(
|
||||
'assistant-timeline-item-${item.key}',
|
||||
),
|
||||
TimelineItemKindInternal.assistant =>
|
||||
MessageBubbleInternal(
|
||||
label: item.label!,
|
||||
text: item.text!,
|
||||
alignRight: false,
|
||||
tone: BubbleToneInternal.assistant,
|
||||
messageViewMode: messageViewMode,
|
||||
),
|
||||
TimelineItemKindInternal.agent => MessageBubbleInternal(
|
||||
label: item.label!,
|
||||
text: item.text!,
|
||||
alignRight: false,
|
||||
tone: BubbleToneInternal.agent,
|
||||
messageViewMode: messageViewMode,
|
||||
),
|
||||
TimelineItemKindInternal.toolCall =>
|
||||
ToolCallTileInternal(
|
||||
toolName: item.title!,
|
||||
summary: item.text!,
|
||||
pending: item.pending,
|
||||
error: item.error,
|
||||
onOpenDetail: () => onOpenDetail(
|
||||
DetailPanelData(
|
||||
title: item.title!,
|
||||
subtitle: appText('工具调用', 'Tool Call'),
|
||||
icon: Icons.build_circle_outlined,
|
||||
status: StatusInfo(
|
||||
item.pending
|
||||
? appText('运行中', 'Running')
|
||||
: appText('已完成', 'Completed'),
|
||||
item.error
|
||||
? StatusTone.danger
|
||||
: StatusTone.accent,
|
||||
child: switch (item.kind) {
|
||||
TimelineItemKindInternal.user =>
|
||||
MessageBubbleInternal(
|
||||
label: item.label!,
|
||||
text: item.text!,
|
||||
alignRight: true,
|
||||
tone: BubbleToneInternal.user,
|
||||
messageViewMode: messageViewMode,
|
||||
),
|
||||
TimelineItemKindInternal.assistant =>
|
||||
MessageBubbleInternal(
|
||||
label: item.label!,
|
||||
text: item.text!,
|
||||
alignRight: false,
|
||||
tone: BubbleToneInternal.assistant,
|
||||
messageViewMode: messageViewMode,
|
||||
),
|
||||
TimelineItemKindInternal.agent =>
|
||||
MessageBubbleInternal(
|
||||
label: item.label!,
|
||||
text: item.text!,
|
||||
alignRight: false,
|
||||
tone: BubbleToneInternal.agent,
|
||||
messageViewMode: messageViewMode,
|
||||
),
|
||||
TimelineItemKindInternal.toolCall =>
|
||||
ToolCallTileInternal(
|
||||
toolName: item.title!,
|
||||
summary: item.text!,
|
||||
pending: item.pending,
|
||||
error: item.error,
|
||||
onOpenDetail: () => onOpenDetail(
|
||||
DetailPanelData(
|
||||
title: item.title!,
|
||||
subtitle: appText('工具调用', 'Tool Call'),
|
||||
icon: Icons.build_circle_outlined,
|
||||
status: StatusInfo(
|
||||
item.pending
|
||||
? appText('运行中', 'Running')
|
||||
: appText('已完成', 'Completed'),
|
||||
item.error
|
||||
? StatusTone.danger
|
||||
: StatusTone.accent,
|
||||
),
|
||||
description: item.text ?? '',
|
||||
meta: [
|
||||
controller.currentSessionKey,
|
||||
controller.activeAgentName,
|
||||
],
|
||||
actions: [appText('复制', 'Copy')],
|
||||
sections: const [],
|
||||
),
|
||||
description: item.text ?? '',
|
||||
meta: [
|
||||
controller.currentSessionKey,
|
||||
controller.activeAgentName,
|
||||
],
|
||||
actions: [appText('复制', 'Copy')],
|
||||
sections: const [],
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@ -491,29 +491,22 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
|
||||
.map((item) => item.sessionKey),
|
||||
);
|
||||
synchronizeTaskSeedsInternal(controller);
|
||||
final entries =
|
||||
taskSeedsInternal.values
|
||||
.where((item) => !isArchivedTaskInternal(item.sessionKey))
|
||||
.map((item) {
|
||||
final isCurrent = sessionKeysMatchInternal(
|
||||
item.sessionKey,
|
||||
controller.currentSessionKey,
|
||||
);
|
||||
final entry = item.toEntry(isCurrent: isCurrent);
|
||||
if (!isCurrent) {
|
||||
return entry;
|
||||
}
|
||||
return entry.copyWith(
|
||||
owner: conversationOwnerLabelInternal(controller),
|
||||
);
|
||||
})
|
||||
.toList(growable: true)
|
||||
..sort((left, right) {
|
||||
if (left.isCurrent != right.isCurrent) {
|
||||
return left.isCurrent ? -1 : 1;
|
||||
}
|
||||
return (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0);
|
||||
});
|
||||
final entries = taskSeedsInternal.values
|
||||
.where((item) => !isArchivedTaskInternal(item.sessionKey))
|
||||
.map((item) {
|
||||
final isCurrent = sessionKeysMatchInternal(
|
||||
item.sessionKey,
|
||||
controller.currentSessionKey,
|
||||
);
|
||||
final entry = item.toEntry(isCurrent: isCurrent);
|
||||
if (!isCurrent) {
|
||||
return entry;
|
||||
}
|
||||
return entry.copyWith(
|
||||
owner: conversationOwnerLabelInternal(controller),
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
return entries;
|
||||
}
|
||||
|
||||
@ -547,7 +540,12 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
|
||||
title: resolvedTaskTitleInternal(widget.controller, sessionKey),
|
||||
preview: '',
|
||||
status: 'queued',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
updatedAtMs:
|
||||
taskSeedsInternal[sessionKey]?.updatedAtMs ??
|
||||
widget.controller
|
||||
.taskThreadForSessionInternal(sessionKey)
|
||||
?.updatedAtMs ??
|
||||
DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
owner: conversationOwnerLabelInternal(widget.controller),
|
||||
surface: 'Assistant',
|
||||
executionTarget: resolvedVisibleExecutionTargetInternal(
|
||||
@ -597,6 +595,10 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
|
||||
}
|
||||
|
||||
final currentSeed = taskSeedsInternal[controller.currentSessionKey];
|
||||
final currentSession = sessionByKeyInternal(
|
||||
controller,
|
||||
controller.currentSessionKey,
|
||||
);
|
||||
final currentPreview = currentTaskPreviewInternal(controller.chatMessages);
|
||||
final currentStatus = currentTaskStatusInternal(
|
||||
controller.chatMessages,
|
||||
@ -621,7 +623,10 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
|
||||
'Waiting for the first message of this task',
|
||||
),
|
||||
status: currentStatus ?? currentSeed?.status ?? 'queued',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
updatedAtMs:
|
||||
currentSession?.updatedAtMs ??
|
||||
currentSeed?.updatedAtMs ??
|
||||
DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
owner: conversationOwnerLabelInternal(controller),
|
||||
surface: currentSeed?.surface ?? 'Assistant',
|
||||
executionTarget: controller.assistantExecutionTargetForSession(
|
||||
|
||||
@ -465,6 +465,7 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
|
||||
if ((message.toolName ?? '').trim().isNotEmpty) {
|
||||
items.add(
|
||||
TimelineItemInternal.toolCall(
|
||||
key: timelineItemKeyInternal(message),
|
||||
toolName: message.toolName!,
|
||||
summary: message.text,
|
||||
pending: message.pending,
|
||||
@ -478,6 +479,7 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
|
||||
if (role == 'user') {
|
||||
items.add(
|
||||
TimelineItemInternal.message(
|
||||
key: timelineItemKeyInternal(message),
|
||||
kind: TimelineItemKindInternal.user,
|
||||
label: appText('你', 'You'),
|
||||
text: message.text,
|
||||
@ -488,6 +490,7 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
|
||||
} else if (role == 'assistant') {
|
||||
items.add(
|
||||
TimelineItemInternal.message(
|
||||
key: timelineItemKeyInternal(message),
|
||||
kind: TimelineItemKindInternal.assistant,
|
||||
label: kProductBrandName,
|
||||
text: message.text,
|
||||
@ -498,6 +501,7 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
|
||||
} else {
|
||||
items.add(
|
||||
TimelineItemInternal.message(
|
||||
key: timelineItemKeyInternal(message),
|
||||
kind: TimelineItemKindInternal.agent,
|
||||
label: lastAutoAgentLabelInternal ?? ownerLabel,
|
||||
text: message.text,
|
||||
@ -510,4 +514,12 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
String timelineItemKeyInternal(GatewayChatMessage message) {
|
||||
final id = message.id.trim();
|
||||
if (id.isNotEmpty) {
|
||||
return id;
|
||||
}
|
||||
return '${message.role}:${message.timestampMs}:${message.text.hashCode}';
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,6 +43,7 @@ enum TimelineItemKindInternal { user, assistant, agent, toolCall }
|
||||
|
||||
class TimelineItemInternal {
|
||||
const TimelineItemInternal._({
|
||||
required this.key,
|
||||
required this.kind,
|
||||
this.label,
|
||||
this.text,
|
||||
@ -52,12 +53,14 @@ class TimelineItemInternal {
|
||||
});
|
||||
|
||||
const TimelineItemInternal.message({
|
||||
required String key,
|
||||
required TimelineItemKindInternal kind,
|
||||
required String label,
|
||||
required String text,
|
||||
required bool pending,
|
||||
required bool error,
|
||||
}) : this._(
|
||||
key: key,
|
||||
kind: kind,
|
||||
label: label,
|
||||
text: text,
|
||||
@ -66,11 +69,13 @@ class TimelineItemInternal {
|
||||
);
|
||||
|
||||
const TimelineItemInternal.toolCall({
|
||||
required String key,
|
||||
required String toolName,
|
||||
required String summary,
|
||||
required bool pending,
|
||||
required bool error,
|
||||
}) : this._(
|
||||
key: key,
|
||||
kind: TimelineItemKindInternal.toolCall,
|
||||
title: toolName,
|
||||
text: summary,
|
||||
@ -78,6 +83,7 @@ class TimelineItemInternal {
|
||||
error: error,
|
||||
);
|
||||
|
||||
final String key;
|
||||
final TimelineItemKindInternal kind;
|
||||
final String? label;
|
||||
final String? text;
|
||||
|
||||
@ -62,6 +62,7 @@ class SidebarTaskSection extends StatefulWidget {
|
||||
|
||||
class _SidebarTaskSectionState extends State<SidebarTaskSection> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final Set<AssistantExecutionTarget> _expandedTargets =
|
||||
<AssistantExecutionTarget>{};
|
||||
String _query = '';
|
||||
@ -83,6 +84,7 @@ class _SidebarTaskSectionState extends State<SidebarTaskSection> {
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -196,70 +198,94 @@ class _SidebarTaskSectionState extends State<SidebarTaskSection> {
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
child: ListView(
|
||||
key: const PageStorageKey<String>('workspace-sidebar-task-list'),
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 0, 4),
|
||||
children: [
|
||||
for (final group in groups) ...[
|
||||
_SidebarTaskGroupHeader(
|
||||
executionTarget: group.executionTarget,
|
||||
count: group.items.length,
|
||||
expanded: _expandedTargets.contains(group.executionTarget),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (_expandedTargets.contains(group.executionTarget)) {
|
||||
_expandedTargets.remove(group.executionTarget);
|
||||
} else {
|
||||
_expandedTargets.add(group.executionTarget);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_expandedTargets.contains(group.executionTarget)) ...[
|
||||
if (group.items.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 0, 8, 6),
|
||||
child: Text(
|
||||
appText('当前分组没有任务。', 'No tasks in this group.'),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textMuted,
|
||||
for (final group in groups)
|
||||
KeyedSubtree(
|
||||
key: ValueKey<String>(
|
||||
'workspace-sidebar-task-group-block-${group.executionTarget.name}',
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SidebarTaskGroupHeader(
|
||||
executionTarget: group.executionTarget,
|
||||
count: group.items.length,
|
||||
expanded: _expandedTargets.contains(
|
||||
group.executionTarget,
|
||||
),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (_expandedTargets.contains(
|
||||
group.executionTarget,
|
||||
)) {
|
||||
_expandedTargets.remove(group.executionTarget);
|
||||
} else {
|
||||
_expandedTargets.add(group.executionTarget);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
for (final item in group.items)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: _SidebarTaskTile(
|
||||
item: item,
|
||||
onTap: widget.onSelectTask == null
|
||||
? null
|
||||
: () async {
|
||||
await widget.onSelectTask!(item.sessionKey);
|
||||
},
|
||||
onArchive:
|
||||
widget.onArchiveTask == null || item.pending
|
||||
? null
|
||||
: () async {
|
||||
await widget.onArchiveTask!(item.sessionKey);
|
||||
},
|
||||
onRename: widget.onRenameTask == null
|
||||
? null
|
||||
: () async {
|
||||
final renamed = await _promptRenameTask(
|
||||
context,
|
||||
item.title,
|
||||
);
|
||||
if (!mounted || renamed == null) {
|
||||
return;
|
||||
}
|
||||
await widget.onRenameTask!(
|
||||
item.sessionKey,
|
||||
renamed,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
if (_expandedTargets.contains(
|
||||
group.executionTarget,
|
||||
)) ...[
|
||||
if (group.items.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 0, 8, 6),
|
||||
child: Text(
|
||||
appText('当前分组没有任务。', 'No tasks in this group.'),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final item in group.items)
|
||||
Padding(
|
||||
key: ValueKey<String>(
|
||||
'workspace-sidebar-task-row-${item.sessionKey}',
|
||||
),
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: _SidebarTaskTile(
|
||||
item: item,
|
||||
onTap: widget.onSelectTask == null
|
||||
? null
|
||||
: () async {
|
||||
await widget.onSelectTask!(
|
||||
item.sessionKey,
|
||||
);
|
||||
},
|
||||
onArchive:
|
||||
widget.onArchiveTask == null || item.pending
|
||||
? null
|
||||
: () async {
|
||||
await widget.onArchiveTask!(
|
||||
item.sessionKey,
|
||||
);
|
||||
},
|
||||
onRename: widget.onRenameTask == null
|
||||
? null
|
||||
: () async {
|
||||
final renamed = await _promptRenameTask(
|
||||
context,
|
||||
item.title,
|
||||
);
|
||||
if (!mounted || renamed == null) {
|
||||
return;
|
||||
}
|
||||
await widget.onRenameTask!(
|
||||
item.sessionKey,
|
||||
renamed,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -167,6 +167,41 @@ void main() {
|
||||
lessThan(_textTop(tester, lastTitle)),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('sidebar keeps scroll position when task content refreshes', (
|
||||
tester,
|
||||
) async {
|
||||
var items = _manySidebarItems(
|
||||
selectedSessionKey: 'task-10',
|
||||
previewSuffix: 'before refresh',
|
||||
);
|
||||
|
||||
Future<void> pump() async {
|
||||
await _pumpSidebar(tester, items: items, height: 360);
|
||||
}
|
||||
|
||||
await pump();
|
||||
await tester.drag(
|
||||
find.byKey(const PageStorageKey<String>('workspace-sidebar-task-list')),
|
||||
const Offset(0, -420),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final anchorFinder = find.byKey(
|
||||
const ValueKey<String>('workspace-sidebar-task-item-task-10'),
|
||||
);
|
||||
final anchorTopBefore = tester.getTopLeft(anchorFinder).dy;
|
||||
|
||||
items = _manySidebarItems(
|
||||
selectedSessionKey: 'task-10',
|
||||
previewSuffix: 'after refresh',
|
||||
);
|
||||
await pump();
|
||||
|
||||
expect(tester.getTopLeft(anchorFinder).dy, closeTo(anchorTopBefore, 0.1));
|
||||
expect(_textTop(tester, '任务 09'), lessThan(_textTop(tester, '任务 10')));
|
||||
expect(_textTop(tester, '任务 10'), lessThan(_textTop(tester, '任务 11')));
|
||||
});
|
||||
}
|
||||
|
||||
double _textTop(WidgetTester tester, String text) =>
|
||||
@ -175,6 +210,7 @@ double _textTop(WidgetTester tester, String text) =>
|
||||
Future<void> _pumpSidebar(
|
||||
WidgetTester tester, {
|
||||
required List<SidebarTaskItem> items,
|
||||
double height = 720,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@ -182,7 +218,7 @@ Future<void> _pumpSidebar(
|
||||
home: Material(
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
height: 720,
|
||||
height: height,
|
||||
child: SidebarNavigation(
|
||||
currentSection: WorkspaceDestination.assistant,
|
||||
sidebarState: AppSidebarState.expanded,
|
||||
@ -208,3 +244,21 @@ Future<void> _pumpSidebar(
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
List<SidebarTaskItem> _manySidebarItems({
|
||||
required String selectedSessionKey,
|
||||
required String previewSuffix,
|
||||
}) {
|
||||
return List<SidebarTaskItem>.generate(18, (index) {
|
||||
final sessionKey = 'task-${index.toString().padLeft(2, '0')}';
|
||||
return SidebarTaskItem(
|
||||
sessionKey: sessionKey,
|
||||
title: '任务 ${index.toString().padLeft(2, '0')}',
|
||||
preview: '刷新内容 $previewSuffix',
|
||||
updatedAtMs: (1000 + index).toDouble(),
|
||||
executionTarget: AssistantExecutionTarget.gateway,
|
||||
isCurrent: sessionKey == selectedSessionKey,
|
||||
pending: false,
|
||||
);
|
||||
}, growable: false);
|
||||
}
|
||||
|
||||
@ -114,4 +114,110 @@ void main() {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
});
|
||||
|
||||
testWidgets('does not scroll when current message metadata refreshes', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
final pageKey = GlobalKey<AssistantPageStateInternal>();
|
||||
const sessionKey = 'stable-scroll-task';
|
||||
|
||||
await controller.sessionsController.switchSession(sessionKey);
|
||||
controller.localSessionMessagesInternal[sessionKey] = _conversationMessages(
|
||||
count: 18,
|
||||
pendingLast: true,
|
||||
);
|
||||
|
||||
await _pumpAssistantPage(tester, controller: controller, pageKey: pageKey);
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
final scrollController =
|
||||
pageKey.currentState!.conversationControllerInternal;
|
||||
scrollController.jumpTo(120);
|
||||
await tester.pump();
|
||||
final offsetBefore = scrollController.offset;
|
||||
|
||||
controller.localSessionMessagesInternal[sessionKey] = _conversationMessages(
|
||||
count: 18,
|
||||
pendingLast: false,
|
||||
);
|
||||
// ignore: invalid_use_of_protected_member
|
||||
pageKey.currentState!.setState(() {});
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(scrollController.offset, offsetBefore);
|
||||
|
||||
controller.localSessionMessagesInternal[sessionKey] = _conversationMessages(
|
||||
count: 19,
|
||||
pendingLast: false,
|
||||
);
|
||||
expect(controller.chatMessages.length, 19);
|
||||
// ignore: invalid_use_of_protected_member
|
||||
pageKey.currentState!.setState(() {});
|
||||
await tester.pump();
|
||||
expect(
|
||||
pageKey.currentState!.lastConversationScrollSignatureInternal,
|
||||
'stable-scroll-task:19:message-18',
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(scrollController.offset, greaterThan(offsetBefore));
|
||||
expect(
|
||||
scrollController.offset,
|
||||
greaterThanOrEqualTo(scrollController.position.maxScrollExtent - 1),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpAssistantPage(
|
||||
WidgetTester tester, {
|
||||
required AppController controller,
|
||||
required GlobalKey<AssistantPageStateInternal> pageKey,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Material(
|
||||
child: SizedBox(
|
||||
width: 1280,
|
||||
height: 520,
|
||||
child: AssistantPage(
|
||||
key: pageKey,
|
||||
controller: controller,
|
||||
showStandaloneTaskRail: false,
|
||||
onOpenDetail: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<GatewayChatMessage> _conversationMessages({
|
||||
required int count,
|
||||
required bool pendingLast,
|
||||
}) {
|
||||
return List<GatewayChatMessage>.generate(count, (index) {
|
||||
final isLast = index == count - 1;
|
||||
return GatewayChatMessage(
|
||||
id: 'message-$index',
|
||||
role: index.isEven ? 'user' : 'assistant',
|
||||
text:
|
||||
'message $index ${'long content keeps the conversation scrollable ' * 8}',
|
||||
timestampMs: index.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: isLast && pendingLast,
|
||||
error: false,
|
||||
);
|
||||
}, growable: false);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user