Fix task refresh layout stability

This commit is contained in:
Haitao Pan 2026-05-30 12:02:03 +08:00
parent 71bae611fc
commit e7a7ba92c4
7 changed files with 352 additions and 136 deletions

View File

@ -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 [],
),
),
),
};
},
);
},
),
),

View File

@ -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(

View File

@ -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}';
}
}

View File

@ -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;

View File

@ -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),
],
),
),
],
),
),

View File

@ -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);
}

View File

@ -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);
}