diff --git a/lib/features/assistant/assistant_page_main.dart b/lib/features/assistant/assistant_page_main.dart index 8189ff57..17afc9b9 100644 --- a/lib/features/assistant/assistant_page_main.dart +++ b/lib/features/assistant/assistant_page_main.dart @@ -179,7 +179,7 @@ class AssistantPageStateInternal extends State { ); 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( + '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 [], ), ), - ), - }; + }, + ); }, ), ), diff --git a/lib/features/assistant/assistant_page_state_actions.dart b/lib/features/assistant/assistant_page_state_actions.dart index 7b563b5d..40a2d555 100644 --- a/lib/features/assistant/assistant_page_state_actions.dart +++ b/lib/features/assistant/assistant_page_state_actions.dart @@ -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( diff --git a/lib/features/assistant/assistant_page_state_closure.dart b/lib/features/assistant/assistant_page_state_closure.dart index ba1aa2d4..96ca7d07 100644 --- a/lib/features/assistant/assistant_page_state_closure.dart +++ b/lib/features/assistant/assistant_page_state_closure.dart @@ -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}'; + } } diff --git a/lib/features/assistant/assistant_page_task_models.dart b/lib/features/assistant/assistant_page_task_models.dart index ee87dbf4..7fa2c9f4 100644 --- a/lib/features/assistant/assistant_page_task_models.dart +++ b/lib/features/assistant/assistant_page_task_models.dart @@ -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; diff --git a/lib/widgets/sidebar_navigation_task_section.dart b/lib/widgets/sidebar_navigation_task_section.dart index 425a8c34..990da717 100644 --- a/lib/widgets/sidebar_navigation_task_section.dart +++ b/lib/widgets/sidebar_navigation_task_section.dart @@ -62,6 +62,7 @@ class SidebarTaskSection extends StatefulWidget { class _SidebarTaskSectionState extends State { final TextEditingController _searchController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); final Set _expandedTargets = {}; String _query = ''; @@ -83,6 +84,7 @@ class _SidebarTaskSectionState extends State { @override void dispose() { _searchController.dispose(); + _scrollController.dispose(); super.dispose(); } @@ -196,70 +198,94 @@ class _SidebarTaskSectionState extends State { Expanded( child: Scrollbar( child: ListView( + key: const PageStorageKey('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( + '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( + '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), + ], + ), + ), ], ), ), diff --git a/test/features/app/sidebar_navigation_task_status_test.dart b/test/features/app/sidebar_navigation_task_status_test.dart index 95868e2b..0a398b2c 100644 --- a/test/features/app/sidebar_navigation_task_status_test.dart +++ b/test/features/app/sidebar_navigation_task_status_test.dart @@ -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 pump() async { + await _pumpSidebar(tester, items: items, height: 360); + } + + await pump(); + await tester.drag( + find.byKey(const PageStorageKey('workspace-sidebar-task-list')), + const Offset(0, -420), + ); + await tester.pump(); + + final anchorFinder = find.byKey( + const ValueKey('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 _pumpSidebar( WidgetTester tester, { required List items, + double height = 720, }) async { await tester.pumpWidget( MaterialApp( @@ -182,7 +218,7 @@ Future _pumpSidebar( home: Material( child: SizedBox( width: 360, - height: 720, + height: height, child: SidebarNavigation( currentSection: WorkspaceDestination.assistant, sidebarState: AppSidebarState.expanded, @@ -208,3 +244,21 @@ Future _pumpSidebar( ); await tester.pump(); } + +List _manySidebarItems({ + required String selectedSessionKey, + required String previewSuffix, +}) { + return List.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); +} diff --git a/test/features/assistant/assistant_page_session_binding_test.dart b/test/features/assistant/assistant_page_session_binding_test.dart index 484435ef..247af96d 100644 --- a/test/features/assistant/assistant_page_session_binding_test.dart +++ b/test/features/assistant/assistant_page_session_binding_test.dart @@ -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 {}, + ); + addTearDown(controller.dispose); + final pageKey = GlobalKey(); + 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 _pumpAssistantPage( + WidgetTester tester, { + required AppController controller, + required GlobalKey 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 _conversationMessages({ + required int count, + required bool pendingLast, +}) { + return List.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); }