feat: unify assistant sidebar and task list
This commit is contained in:
parent
9c47eef881
commit
2e467fa39e
@ -1,16 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../features/account/account_page.dart';
|
||||
import '../features/ai_gateway/ai_gateway_page.dart';
|
||||
import '../features/assistant/assistant_page.dart';
|
||||
import '../features/claw_hub/claw_hub_page.dart';
|
||||
import '../features/mcp_server/mcp_server_page.dart';
|
||||
import '../features/mobile/ios_mobile_shell.dart';
|
||||
import '../features/modules/modules_page.dart';
|
||||
import '../features/secrets/secrets_page.dart';
|
||||
import '../features/settings/settings_page.dart';
|
||||
import '../features/skills/skills_page.dart';
|
||||
import '../features/tasks/tasks_page.dart';
|
||||
import '../features/account/account_page.dart';
|
||||
import '../features/ai_gateway/ai_gateway_page.dart';
|
||||
import '../features/assistant/assistant_page.dart';
|
||||
import '../features/claw_hub/claw_hub_page.dart';
|
||||
import '../features/mcp_server/mcp_server_page.dart';
|
||||
import '../features/mobile/ios_mobile_shell.dart';
|
||||
import '../features/modules/modules_page.dart';
|
||||
import '../features/secrets/secrets_page.dart';
|
||||
import '../features/settings/settings_page.dart';
|
||||
import '../features/skills/skills_page.dart';
|
||||
import '../features/tasks/tasks_page.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
@ -71,6 +71,9 @@ class _AppShellState extends State<AppShell> {
|
||||
final isMobile = constraints.maxWidth < 900;
|
||||
final sidebarState = controller.sidebarState;
|
||||
final showSidebar = sidebarState != AppSidebarState.hidden;
|
||||
final embedSidebarIntoAssistant =
|
||||
controller.destination == WorkspaceDestination.assistant &&
|
||||
showSidebar;
|
||||
final expandedSidebarWidth = _clampSidebarWidth(
|
||||
_sidebarExpandedWidth ??
|
||||
_defaultSidebarWidth(
|
||||
@ -197,7 +200,7 @@ class _AppShellState extends State<AppShell> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (showSidebar)
|
||||
if (showSidebar && !embedSidebarIntoAssistant)
|
||||
SidebarNavigation(
|
||||
currentSection: controller.destination,
|
||||
sidebarState: sidebarState,
|
||||
@ -233,7 +236,8 @@ class _AppShellState extends State<AppShell> {
|
||||
? expandedSidebarWidth
|
||||
: null,
|
||||
),
|
||||
if (sidebarState == AppSidebarState.expanded)
|
||||
if (sidebarState == AppSidebarState.expanded &&
|
||||
!embedSidebarIntoAssistant)
|
||||
PaneResizeHandle(
|
||||
axis: Axis.horizontal,
|
||||
onDelta: (delta) {
|
||||
@ -322,6 +326,41 @@ class _AppShellState extends State<AppShell> {
|
||||
WorkspaceDestination.assistant => AssistantPage(
|
||||
controller: widget.controller,
|
||||
onOpenDetail: onOpenDetail,
|
||||
navigationPanelBuilder:
|
||||
widget.controller.sidebarState == AppSidebarState.hidden
|
||||
? null
|
||||
: (contentWidth) => SidebarNavigation(
|
||||
currentSection: widget.controller.destination,
|
||||
sidebarState: AppSidebarState.expanded,
|
||||
appLanguage: widget.controller.appLanguage,
|
||||
themeMode: widget.controller.themeMode,
|
||||
onSectionChanged: widget.controller.navigateTo,
|
||||
onToggleLanguage: widget.controller.toggleAppLanguage,
|
||||
onCycleSidebarState: widget.controller.cycleSidebarState,
|
||||
onExpandFromCollapsed: () =>
|
||||
widget.controller.setSidebarState(AppSidebarState.expanded),
|
||||
onOpenAccount: () =>
|
||||
widget.controller.navigateTo(WorkspaceDestination.account),
|
||||
onOpenThemeToggle: () => widget.controller.setThemeMode(
|
||||
widget.controller.themeMode == ThemeMode.dark
|
||||
? ThemeMode.light
|
||||
: ThemeMode.dark,
|
||||
),
|
||||
accountName:
|
||||
widget.controller.settings.accountUsername.trim().isEmpty
|
||||
? appText('本地操作员', 'Local Operator')
|
||||
: widget.controller.settings.accountUsername,
|
||||
accountSubtitle:
|
||||
widget.controller.settings.accountWorkspace.trim().isEmpty
|
||||
? appText('账号', 'Account')
|
||||
: widget.controller.settings.accountWorkspace,
|
||||
expandedWidthOverride: contentWidth,
|
||||
marginOverride: EdgeInsets.zero,
|
||||
showCollapseControl: false,
|
||||
),
|
||||
showStandaloneTaskRail: false,
|
||||
unifiedPaneStartsCollapsed:
|
||||
widget.controller.sidebarState == AppSidebarState.collapsed,
|
||||
),
|
||||
WorkspaceDestination.tasks => TasksPage(
|
||||
controller: widget.controller,
|
||||
|
||||
@ -20,10 +20,16 @@ class AssistantPage extends StatefulWidget {
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onOpenDetail,
|
||||
this.navigationPanelBuilder,
|
||||
this.showStandaloneTaskRail = true,
|
||||
this.unifiedPaneStartsCollapsed = false,
|
||||
});
|
||||
|
||||
final AppController controller;
|
||||
final ValueChanged<DetailPanelData> onOpenDetail;
|
||||
final Widget Function(double contentWidth)? navigationPanelBuilder;
|
||||
final bool showStandaloneTaskRail;
|
||||
final bool unifiedPaneStartsCollapsed;
|
||||
|
||||
@override
|
||||
State<AssistantPage> createState() => _AssistantPageState();
|
||||
@ -42,6 +48,11 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
double _conversationPaneRatio = 0.7;
|
||||
double _threadRailWidth = 304;
|
||||
String _threadQuery = '';
|
||||
bool _sidePaneCollapsed = false;
|
||||
_AssistantSidePane _activeSidePane = _AssistantSidePane.tasks;
|
||||
final Map<String, _AssistantTaskSeed> _taskSeeds =
|
||||
<String, _AssistantTaskSeed>{};
|
||||
final Set<String> _archivedTaskKeys = <String>{};
|
||||
List<_ComposerAttachment> _attachments = const <_ComposerAttachment>[];
|
||||
String? _lastSubmittedPrompt;
|
||||
String? _lastAutoAgentLabel;
|
||||
@ -54,6 +65,16 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
_threadSearchController = TextEditingController();
|
||||
_conversationController = ScrollController();
|
||||
_composerFocusNode = FocusNode();
|
||||
_sidePaneCollapsed = widget.unifiedPaneStartsCollapsed;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AssistantPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.unifiedPaneStartsCollapsed !=
|
||||
widget.unifiedPaneStartsCollapsed) {
|
||||
_sidePaneCollapsed = widget.unifiedPaneStartsCollapsed;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@ -95,13 +116,19 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
padding: const EdgeInsets.fromLTRB(6, 6, 6, 0),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final showThreadRail = constraints.maxWidth >= 860;
|
||||
final showUnifiedSidePane =
|
||||
widget.navigationPanelBuilder != null &&
|
||||
constraints.maxWidth >= 860;
|
||||
final showThreadRail =
|
||||
!showUnifiedSidePane &&
|
||||
widget.showStandaloneTaskRail &&
|
||||
constraints.maxWidth >= 860;
|
||||
final mainWorkspace = _buildMainWorkspace(
|
||||
controller: controller,
|
||||
timelineItems: timelineItems,
|
||||
currentTask: currentTask,
|
||||
);
|
||||
if (!showThreadRail) {
|
||||
if (!showThreadRail && !showUnifiedSidePane) {
|
||||
return mainWorkspace;
|
||||
}
|
||||
|
||||
@ -112,6 +139,90 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
.clamp(232.0, maxThreadRailWidth)
|
||||
.toDouble();
|
||||
|
||||
if (showUnifiedSidePane) {
|
||||
const sideTabRailWidth = 58.0;
|
||||
final sidePanelContentWidth =
|
||||
(threadRailWidth - sideTabRailWidth - 6)
|
||||
.clamp(174.0, maxThreadRailWidth)
|
||||
.toDouble();
|
||||
return Row(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: _sidePaneCollapsed
|
||||
? sideTabRailWidth
|
||||
: threadRailWidth,
|
||||
child: _AssistantUnifiedSidePane(
|
||||
activePane: _activeSidePane,
|
||||
collapsed: _sidePaneCollapsed,
|
||||
taskPanel: _AssistantTaskRail(
|
||||
key: const Key('assistant-task-rail'),
|
||||
controller: controller,
|
||||
tasks: visibleTasks,
|
||||
query: _threadQuery,
|
||||
searchController: _threadSearchController,
|
||||
onQueryChanged: (value) {
|
||||
setState(() {
|
||||
_threadQuery = value.trim();
|
||||
});
|
||||
},
|
||||
onClearQuery: () {
|
||||
_threadSearchController.clear();
|
||||
setState(() {
|
||||
_threadQuery = '';
|
||||
});
|
||||
},
|
||||
onRefreshTasks: controller.refreshSessions,
|
||||
onCreateTask: _createNewThread,
|
||||
onOpenTasks: () {
|
||||
controller.navigateTo(WorkspaceDestination.tasks);
|
||||
},
|
||||
onOpenSkills: () {
|
||||
controller.navigateTo(WorkspaceDestination.skills);
|
||||
},
|
||||
onSelectTask: (sessionKey) async {
|
||||
await controller.switchSession(sessionKey);
|
||||
_focusComposer();
|
||||
},
|
||||
onArchiveTask: _archiveTask,
|
||||
),
|
||||
navigationPanel: widget.navigationPanelBuilder!(
|
||||
sidePanelContentWidth,
|
||||
),
|
||||
onSelectPane: (pane) {
|
||||
setState(() {
|
||||
_activeSidePane = pane;
|
||||
_sidePaneCollapsed = false;
|
||||
});
|
||||
},
|
||||
onToggleCollapsed: () {
|
||||
setState(() {
|
||||
_sidePaneCollapsed = !_sidePaneCollapsed;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
if (!_sidePaneCollapsed)
|
||||
SizedBox(
|
||||
width: 10,
|
||||
child: PaneResizeHandle(
|
||||
axis: Axis.horizontal,
|
||||
onDelta: (delta) {
|
||||
setState(() {
|
||||
_threadRailWidth = (_threadRailWidth + delta)
|
||||
.clamp(232.0, maxThreadRailWidth)
|
||||
.toDouble();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: mainWorkspace),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
@ -145,6 +256,7 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
await controller.switchSession(sessionKey);
|
||||
_focusComposer();
|
||||
},
|
||||
onArchiveTask: _archiveTask,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
@ -190,15 +302,13 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
var minComposerHeight = availablePaneHeight >= 620
|
||||
? 176.0
|
||||
: availablePaneHeight * 0.24;
|
||||
if (minConversationHeight + minComposerHeight >
|
||||
availablePaneHeight) {
|
||||
if (minConversationHeight + minComposerHeight > availablePaneHeight) {
|
||||
minConversationHeight = availablePaneHeight * 0.52;
|
||||
minComposerHeight = availablePaneHeight - minConversationHeight;
|
||||
}
|
||||
final maxConversationHeight =
|
||||
(availablePaneHeight - minComposerHeight)
|
||||
.clamp(minConversationHeight, availablePaneHeight)
|
||||
.toDouble();
|
||||
final maxConversationHeight = (availablePaneHeight - minComposerHeight)
|
||||
.clamp(minConversationHeight, availablePaneHeight)
|
||||
.toDouble();
|
||||
final conversationHeight = availablePaneHeight <= 0
|
||||
? 0.0
|
||||
: (_conversationPaneRatio * availablePaneHeight)
|
||||
@ -428,6 +538,20 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
_lastSubmittedPrompt = rawPrompt;
|
||||
_lastAutoAgentLabel = autoAgent?.name ?? controller.activeAgentName;
|
||||
_lastSubmittedAttachments = attachmentNames;
|
||||
_touchTaskSeed(
|
||||
sessionKey: controller.currentSessionKey,
|
||||
title:
|
||||
_taskSeeds[controller.currentSessionKey]?.title ??
|
||||
_fallbackSessionTitle(controller.currentSessionKey),
|
||||
preview: rawPrompt,
|
||||
status:
|
||||
controller.connection.status == RuntimeConnectionStatus.connected
|
||||
? 'running'
|
||||
: 'queued',
|
||||
owner: autoAgent?.name ?? controller.activeAgentName,
|
||||
surface: 'Assistant',
|
||||
draft: controller.currentSessionKey.trim().startsWith('draft:'),
|
||||
);
|
||||
});
|
||||
|
||||
final attachmentPayloads = await _buildAttachmentPayloads(_attachments);
|
||||
@ -578,60 +702,48 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
|
||||
Future<void> _createNewThread() async {
|
||||
final sessionKey = _buildDraftSessionKey(widget.controller);
|
||||
setState(() {
|
||||
_archivedTaskKeys.removeWhere(
|
||||
(value) => _sessionKeysMatch(value, sessionKey),
|
||||
);
|
||||
_taskSeeds[sessionKey] = _AssistantTaskSeed(
|
||||
sessionKey: sessionKey,
|
||||
title: appText('新对话', 'New conversation'),
|
||||
preview: appText(
|
||||
'等待描述这个任务的第一条消息',
|
||||
'Waiting for the first message of this task',
|
||||
),
|
||||
status: 'queued',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
owner: widget.controller.activeAgentName,
|
||||
surface: 'Assistant',
|
||||
draft: true,
|
||||
);
|
||||
});
|
||||
await widget.controller.switchSession(sessionKey);
|
||||
_focusComposer();
|
||||
}
|
||||
|
||||
List<_AssistantTaskEntry> _buildTaskEntries(AppController controller) {
|
||||
final sessions = controller.sessions.toList(growable: false)
|
||||
..sort(
|
||||
(left, right) =>
|
||||
(right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0),
|
||||
);
|
||||
final entries = sessions
|
||||
.map(
|
||||
(session) => _AssistantTaskEntry(
|
||||
sessionKey: session.key,
|
||||
title: _sessionDisplayTitle(session),
|
||||
preview:
|
||||
_sessionPreview(session) ??
|
||||
appText('等待继续执行这个任务', 'Waiting to continue this task'),
|
||||
status: _sessionStatus(
|
||||
session,
|
||||
currentSessionKey: controller.currentSessionKey,
|
||||
hasPendingRun: controller.chatController.hasPendingRun,
|
||||
),
|
||||
updatedAtLabel: _sessionUpdatedAtLabel(session.updatedAtMs),
|
||||
owner: controller.activeAgentName,
|
||||
surface: session.surface ?? session.kind ?? 'Assistant',
|
||||
isCurrent: _sessionKeysMatch(
|
||||
session.key,
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: true);
|
||||
if (!entries.any(
|
||||
(item) => _sessionKeysMatch(item.sessionKey, controller.currentSessionKey),
|
||||
)) {
|
||||
entries.insert(
|
||||
0,
|
||||
_AssistantTaskEntry(
|
||||
sessionKey: controller.currentSessionKey,
|
||||
title: _fallbackSessionTitle(controller.currentSessionKey),
|
||||
preview: appText(
|
||||
'等待描述这个任务的第一条消息',
|
||||
'Waiting for the first message of this task',
|
||||
),
|
||||
status: 'queued',
|
||||
updatedAtLabel: appText('现在', 'Now'),
|
||||
owner: controller.activeAgentName,
|
||||
surface: 'Assistant',
|
||||
isCurrent: true,
|
||||
draft: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
_synchronizeTaskSeeds(controller);
|
||||
final entries =
|
||||
_taskSeeds.values
|
||||
.where((item) => !_isArchivedTask(item.sessionKey))
|
||||
.map(
|
||||
(item) => item.toEntry(
|
||||
isCurrent: _sessionKeysMatch(
|
||||
item.sessionKey,
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: true)
|
||||
..sort((left, right) {
|
||||
if (left.isCurrent != right.isCurrent) {
|
||||
return left.isCurrent ? -1 : 1;
|
||||
}
|
||||
return (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0);
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
@ -640,11 +752,13 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
if (query.isEmpty) {
|
||||
return items;
|
||||
}
|
||||
return items.where((item) {
|
||||
final haystack =
|
||||
'${item.title}\n${item.preview}\n${item.sessionKey}'.toLowerCase();
|
||||
return haystack.contains(query);
|
||||
}).toList(growable: false);
|
||||
return items
|
||||
.where((item) {
|
||||
final haystack = '${item.title}\n${item.preview}\n${item.sessionKey}'
|
||||
.toLowerCase();
|
||||
return haystack.contains(query);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
_AssistantTaskEntry _resolveCurrentTask(
|
||||
@ -661,7 +775,7 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
title: _fallbackSessionTitle(sessionKey),
|
||||
preview: '',
|
||||
status: 'queued',
|
||||
updatedAtLabel: appText('现在', 'Now'),
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
owner: widget.controller.activeAgentName,
|
||||
surface: 'Assistant',
|
||||
isCurrent: true,
|
||||
@ -669,6 +783,109 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _synchronizeTaskSeeds(AppController controller) {
|
||||
for (final session in controller.sessions) {
|
||||
if (_isArchivedTask(session.key)) {
|
||||
continue;
|
||||
}
|
||||
_taskSeeds[session.key] = _AssistantTaskSeed(
|
||||
sessionKey: session.key,
|
||||
title: _sessionDisplayTitle(session),
|
||||
preview:
|
||||
_sessionPreview(session) ??
|
||||
appText('等待继续执行这个任务', 'Waiting to continue this task'),
|
||||
status: _sessionStatus(
|
||||
session,
|
||||
currentSessionKey: controller.currentSessionKey,
|
||||
hasPendingRun: controller.chatController.hasPendingRun,
|
||||
),
|
||||
updatedAtMs:
|
||||
session.updatedAtMs ??
|
||||
DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
owner: controller.activeAgentName,
|
||||
surface: session.surface ?? session.kind ?? 'Assistant',
|
||||
draft: session.key.trim().startsWith('draft:'),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isArchivedTask(controller.currentSessionKey)) {
|
||||
return;
|
||||
}
|
||||
_taskSeeds.putIfAbsent(
|
||||
controller.currentSessionKey,
|
||||
() => _AssistantTaskSeed(
|
||||
sessionKey: controller.currentSessionKey,
|
||||
title: _fallbackSessionTitle(controller.currentSessionKey),
|
||||
preview: appText(
|
||||
'等待描述这个任务的第一条消息',
|
||||
'Waiting for the first message of this task',
|
||||
),
|
||||
status: 'queued',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
owner: controller.activeAgentName,
|
||||
surface: 'Assistant',
|
||||
draft: controller.currentSessionKey.trim().startsWith('draft:'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _touchTaskSeed({
|
||||
required String sessionKey,
|
||||
required String title,
|
||||
required String preview,
|
||||
required String status,
|
||||
required String owner,
|
||||
required String surface,
|
||||
required bool draft,
|
||||
}) {
|
||||
_taskSeeds[sessionKey] = _AssistantTaskSeed(
|
||||
sessionKey: sessionKey,
|
||||
title: title,
|
||||
preview: preview,
|
||||
status: status,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
owner: owner,
|
||||
surface: surface,
|
||||
draft: draft,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isArchivedTask(String sessionKey) {
|
||||
for (final archivedKey in _archivedTaskKeys) {
|
||||
if (_sessionKeysMatch(archivedKey, sessionKey)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _archiveTask(String sessionKey) async {
|
||||
final isCurrent = _sessionKeysMatch(
|
||||
sessionKey,
|
||||
widget.controller.currentSessionKey,
|
||||
);
|
||||
setState(() {
|
||||
_archivedTaskKeys.add(sessionKey);
|
||||
_taskSeeds.removeWhere((key, _) => _sessionKeysMatch(key, sessionKey));
|
||||
});
|
||||
|
||||
if (!isCurrent) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final candidate in _taskSeeds.keys) {
|
||||
if (_isArchivedTask(candidate) ||
|
||||
_sessionKeysMatch(candidate, sessionKey)) {
|
||||
continue;
|
||||
}
|
||||
await widget.controller.switchSession(candidate);
|
||||
_focusComposer();
|
||||
return;
|
||||
}
|
||||
|
||||
await _createNewThread();
|
||||
}
|
||||
|
||||
String _buildDraftSessionKey(AppController controller) {
|
||||
final stamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final selectedAgentId = controller.selectedAgentId.trim();
|
||||
@ -679,6 +896,171 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
}
|
||||
}
|
||||
|
||||
enum _AssistantSidePane { tasks, navigation }
|
||||
|
||||
class _AssistantUnifiedSidePane extends StatelessWidget {
|
||||
const _AssistantUnifiedSidePane({
|
||||
required this.activePane,
|
||||
required this.collapsed,
|
||||
required this.taskPanel,
|
||||
required this.navigationPanel,
|
||||
required this.onSelectPane,
|
||||
required this.onToggleCollapsed,
|
||||
});
|
||||
|
||||
final _AssistantSidePane activePane;
|
||||
final bool collapsed;
|
||||
final Widget taskPanel;
|
||||
final Widget navigationPanel;
|
||||
final ValueChanged<_AssistantSidePane> onSelectPane;
|
||||
final VoidCallback onToggleCollapsed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
_AssistantSideTabRail(
|
||||
activePane: activePane,
|
||||
collapsed: collapsed,
|
||||
onSelectPane: onSelectPane,
|
||||
onToggleCollapsed: onToggleCollapsed,
|
||||
),
|
||||
if (!collapsed) ...[
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
child: activePane == _AssistantSidePane.tasks
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey<String>('assistant-side-pane-tasks'),
|
||||
child: taskPanel,
|
||||
)
|
||||
: KeyedSubtree(
|
||||
key: const ValueKey<String>(
|
||||
'assistant-side-pane-navigation',
|
||||
),
|
||||
child: navigationPanel,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssistantSideTabRail extends StatelessWidget {
|
||||
const _AssistantSideTabRail({
|
||||
required this.activePane,
|
||||
required this.collapsed,
|
||||
required this.onSelectPane,
|
||||
required this.onToggleCollapsed,
|
||||
});
|
||||
|
||||
final _AssistantSidePane activePane;
|
||||
final bool collapsed;
|
||||
final ValueChanged<_AssistantSidePane> onSelectPane;
|
||||
final VoidCallback onToggleCollapsed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
|
||||
return Container(
|
||||
key: const Key('assistant-side-pane'),
|
||||
width: 58,
|
||||
decoration: BoxDecoration(
|
||||
color: palette.sidebar,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: palette.sidebarBorder.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_AssistantSideTabButton(
|
||||
key: const Key('assistant-side-pane-tab-tasks'),
|
||||
icon: Icons.checklist_rtl_rounded,
|
||||
selected: activePane == _AssistantSidePane.tasks,
|
||||
tooltip: appText('任务', 'Tasks'),
|
||||
onTap: () => onSelectPane(_AssistantSidePane.tasks),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_AssistantSideTabButton(
|
||||
key: const Key('assistant-side-pane-tab-navigation'),
|
||||
icon: Icons.dashboard_customize_outlined,
|
||||
selected: activePane == _AssistantSidePane.navigation,
|
||||
tooltip: appText('导航', 'Navigation'),
|
||||
onTap: () => onSelectPane(_AssistantSidePane.navigation),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
key: const Key('assistant-side-pane-toggle'),
|
||||
tooltip: collapsed
|
||||
? appText('展开侧板', 'Expand side pane')
|
||||
: appText('收起侧板', 'Collapse side pane'),
|
||||
onPressed: onToggleCollapsed,
|
||||
icon: Icon(
|
||||
collapsed
|
||||
? Icons.keyboard_double_arrow_right_rounded
|
||||
: Icons.keyboard_double_arrow_left_rounded,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssistantSideTabButton extends StatelessWidget {
|
||||
const _AssistantSideTabButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
required this.tooltip,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final bool selected;
|
||||
final String tooltip;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? palette.accentMuted : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: selected ? palette.accent : palette.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssistantLowerPane extends StatelessWidget {
|
||||
const _AssistantLowerPane({
|
||||
required this.controller,
|
||||
@ -776,8 +1158,8 @@ class _ConversationArea extends StatelessWidget {
|
||||
final palette = context.palette;
|
||||
final theme = Theme.of(context);
|
||||
final statusStyle = _pillStyleForStatus(context, currentTask.status);
|
||||
final taskHint = controller.connection.status ==
|
||||
RuntimeConnectionStatus.connected
|
||||
final taskHint =
|
||||
controller.connection.status == RuntimeConnectionStatus.connected
|
||||
? appText(
|
||||
'当前对话会作为任务上下文持续执行,切换左侧任务即可回到对应会话。',
|
||||
'This conversation stays attached to the selected task. Pick another task on the left to jump back into it.',
|
||||
@ -806,10 +1188,7 @@ class _ConversationArea extends StatelessWidget {
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
taskHint,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
Text(taskHint, style: theme.textTheme.bodySmall),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
@ -986,6 +1365,7 @@ class _AssistantTaskRail extends StatelessWidget {
|
||||
required this.onOpenTasks,
|
||||
required this.onOpenSkills,
|
||||
required this.onSelectTask,
|
||||
required this.onArchiveTask,
|
||||
});
|
||||
|
||||
final AppController controller;
|
||||
@ -999,6 +1379,7 @@ class _AssistantTaskRail extends StatelessWidget {
|
||||
final VoidCallback onOpenTasks;
|
||||
final VoidCallback onOpenSkills;
|
||||
final Future<void> Function(String sessionKey) onSelectTask;
|
||||
final Future<void> Function(String sessionKey) onArchiveTask;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -1100,8 +1481,7 @@ class _AssistantTaskRail extends StatelessWidget {
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_MetaPill(
|
||||
label:
|
||||
'${appText('运行中', 'Running')} $runningCount',
|
||||
label: '${appText('运行中', 'Running')} $runningCount',
|
||||
icon: Icons.play_circle_outline_rounded,
|
||||
),
|
||||
_MetaPill(
|
||||
@ -1186,6 +1566,9 @@ class _AssistantTaskRail extends StatelessWidget {
|
||||
onTap: () async {
|
||||
await onSelectTask(task.sessionKey);
|
||||
},
|
||||
onArchive: () async {
|
||||
await onArchiveTask(task.sessionKey);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -1197,10 +1580,15 @@ class _AssistantTaskRail extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _AssistantTaskTile extends StatelessWidget {
|
||||
const _AssistantTaskTile({required this.entry, required this.onTap});
|
||||
const _AssistantTaskTile({
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
required this.onArchive,
|
||||
});
|
||||
|
||||
final _AssistantTaskEntry entry;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onArchive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -1214,7 +1602,7 @@ class _AssistantTaskTile extends StatelessWidget {
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
key: ValueKey<String>('assistant-task-${entry.sessionKey}'),
|
||||
key: ValueKey<String>('assistant-task-item-${entry.sessionKey}'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
@ -1263,11 +1651,31 @@ class _AssistantTaskTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
entry.updatedAtLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textMuted,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
entry.updatedAtLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
IconButton(
|
||||
key: ValueKey<String>(
|
||||
'assistant-task-archive-${entry.sessionKey}',
|
||||
),
|
||||
tooltip: appText('归档任务', 'Archive task'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
splashRadius: 16,
|
||||
onPressed: onArchive,
|
||||
icon: Icon(
|
||||
Icons.archive_outlined,
|
||||
size: 18,
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -1282,7 +1690,10 @@ class _AssistantTaskTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_StatusPill(
|
||||
label: entry.draft
|
||||
@ -1291,21 +1702,8 @@ class _AssistantTaskTile extends StatelessWidget {
|
||||
backgroundColor: statusStyle.backgroundColor,
|
||||
textColor: statusStyle.foregroundColor,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: _MetaPill(
|
||||
label: entry.owner,
|
||||
icon: Icons.smart_toy_outlined,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: _MetaPill(
|
||||
label: entry.surface,
|
||||
icon: Icons.forum_outlined,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_MetaPill(label: entry.owner, icon: Icons.smart_toy_outlined),
|
||||
_MetaPill(label: entry.surface, icon: Icons.forum_outlined),
|
||||
if (entry.isCurrent)
|
||||
Text(
|
||||
appText('当前', 'Current'),
|
||||
@ -1880,7 +2278,10 @@ class _ComposerToolbarChip extends StatelessWidget {
|
||||
final palette = context.palette;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xs,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? palette.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.chip),
|
||||
@ -2238,7 +2639,12 @@ class _ToolCallTileState extends State<_ToolCallTile> {
|
||||
curve: Curves.easeOutCubic,
|
||||
child: _expanded
|
||||
? Padding(
|
||||
padding: const EdgeInsets.fromLTRB(AppSpacing.sm, 0, AppSpacing.sm, AppSpacing.xs),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.sm,
|
||||
0,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.xs,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -2322,7 +2728,10 @@ class _ConnectionChip extends StatelessWidget {
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs, vertical: 5),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xs,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(AppRadius.chip),
|
||||
@ -2425,13 +2834,49 @@ class _TimelineItem {
|
||||
final bool error;
|
||||
}
|
||||
|
||||
class _AssistantTaskSeed {
|
||||
const _AssistantTaskSeed({
|
||||
required this.sessionKey,
|
||||
required this.title,
|
||||
required this.preview,
|
||||
required this.status,
|
||||
required this.updatedAtMs,
|
||||
required this.owner,
|
||||
required this.surface,
|
||||
required this.draft,
|
||||
});
|
||||
|
||||
final String sessionKey;
|
||||
final String title;
|
||||
final String preview;
|
||||
final String status;
|
||||
final double updatedAtMs;
|
||||
final String owner;
|
||||
final String surface;
|
||||
final bool draft;
|
||||
|
||||
_AssistantTaskEntry toEntry({required bool isCurrent}) {
|
||||
return _AssistantTaskEntry(
|
||||
sessionKey: sessionKey,
|
||||
title: title,
|
||||
preview: preview,
|
||||
status: status,
|
||||
updatedAtMs: updatedAtMs,
|
||||
owner: owner,
|
||||
surface: surface,
|
||||
isCurrent: isCurrent,
|
||||
draft: draft,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssistantTaskEntry {
|
||||
const _AssistantTaskEntry({
|
||||
required this.sessionKey,
|
||||
required this.title,
|
||||
required this.preview,
|
||||
required this.status,
|
||||
required this.updatedAtLabel,
|
||||
required this.updatedAtMs,
|
||||
required this.owner,
|
||||
required this.surface,
|
||||
required this.isCurrent,
|
||||
@ -2442,11 +2887,13 @@ class _AssistantTaskEntry {
|
||||
final String title;
|
||||
final String preview;
|
||||
final String status;
|
||||
final String updatedAtLabel;
|
||||
final double? updatedAtMs;
|
||||
final String owner;
|
||||
final String surface;
|
||||
final bool isCurrent;
|
||||
final bool draft;
|
||||
|
||||
String get updatedAtLabel => _sessionUpdatedAtLabel(updatedAtMs);
|
||||
}
|
||||
|
||||
class _PillStyle {
|
||||
@ -2470,30 +2917,45 @@ class _MetaPill extends StatelessWidget {
|
||||
final palette = context.palette;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: palette.strokeSoft),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: palette.textMuted),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
),
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
if (maxWidth.isFinite && maxWidth < 20) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final showText = !maxWidth.isFinite || maxWidth >= 52;
|
||||
final horizontalPadding = showText ? 10.0 : 8.0;
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
vertical: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: palette.strokeSoft),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: palette.textMuted),
|
||||
if (showText) ...[
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,8 @@ class SidebarNavigation extends StatelessWidget {
|
||||
required this.accountName,
|
||||
required this.accountSubtitle,
|
||||
this.expandedWidthOverride,
|
||||
this.marginOverride,
|
||||
this.showCollapseControl = true,
|
||||
});
|
||||
|
||||
final WorkspaceDestination currentSection;
|
||||
@ -36,6 +38,8 @@ class SidebarNavigation extends StatelessWidget {
|
||||
final String accountName;
|
||||
final String accountSubtitle;
|
||||
final double? expandedWidthOverride;
|
||||
final EdgeInsetsGeometry? marginOverride;
|
||||
final bool showCollapseControl;
|
||||
|
||||
static const _primarySections = <WorkspaceDestination>[
|
||||
WorkspaceDestination.assistant,
|
||||
@ -69,7 +73,9 @@ class SidebarNavigation extends StatelessWidget {
|
||||
curve: Curves.easeOutCubic,
|
||||
width: isExpanded ? expandedWidth : AppSizes.sidebarCollapsedWidth,
|
||||
height: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(AppSpacing.xs, AppSpacing.xs, 6, 0),
|
||||
margin:
|
||||
marginOverride ??
|
||||
const EdgeInsets.fromLTRB(AppSpacing.xs, AppSpacing.xs, 6, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.sidebar,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sidebar),
|
||||
@ -145,6 +151,7 @@ class SidebarNavigation extends StatelessWidget {
|
||||
accountSubtitle: accountSubtitle,
|
||||
accountSelected:
|
||||
currentSection == WorkspaceDestination.account,
|
||||
showCollapseControl: showCollapseControl,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -404,6 +411,7 @@ class SidebarFooter extends StatelessWidget {
|
||||
required this.accountName,
|
||||
required this.accountSubtitle,
|
||||
required this.accountSelected,
|
||||
required this.showCollapseControl,
|
||||
});
|
||||
|
||||
final bool isCollapsed;
|
||||
@ -419,6 +427,7 @@ class SidebarFooter extends StatelessWidget {
|
||||
final String accountName;
|
||||
final String accountSubtitle;
|
||||
final bool accountSelected;
|
||||
final bool showCollapseControl;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -450,12 +459,14 @@ class SidebarFooter extends StatelessWidget {
|
||||
onPressed: onOpenThemeToggle,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
_SidebarActionButton(
|
||||
icon: _sidebarStateIcon(sidebarState),
|
||||
tooltip: _sidebarStateLabel(sidebarState),
|
||||
onPressed: onCycleSidebarState,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
if (showCollapseControl) ...[
|
||||
_SidebarActionButton(
|
||||
icon: _sidebarStateIcon(sidebarState),
|
||||
tooltip: _sidebarStateLabel(sidebarState),
|
||||
onPressed: onCycleSidebarState,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
],
|
||||
_SidebarActionButton(
|
||||
icon: Icons.tune_rounded,
|
||||
tooltip: appText('设置', 'Settings'),
|
||||
@ -507,11 +518,12 @@ class SidebarFooter extends StatelessWidget {
|
||||
onPressed: onOpenThemeToggle,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
_SidebarActionButton(
|
||||
icon: _sidebarStateIcon(sidebarState),
|
||||
tooltip: _sidebarStateLabel(sidebarState),
|
||||
onPressed: onCycleSidebarState,
|
||||
),
|
||||
if (showCollapseControl)
|
||||
_SidebarActionButton(
|
||||
icon: _sidebarStateIcon(sidebarState),
|
||||
tooltip: _sidebarStateLabel(sidebarState),
|
||||
onPressed: onCycleSidebarState,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
|
||||
@ -5,7 +5,34 @@ import 'package:xworkmate/features/assistant/assistant_page.dart';
|
||||
import '../test_support.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('AssistantPage desktop shows thread rail and creates draft thread', (
|
||||
testWidgets(
|
||||
'AssistantPage desktop shows thread rail and creates draft thread',
|
||||
(WidgetTester tester) async {
|
||||
final controller = await createTestController(tester);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget);
|
||||
|
||||
final titleBefore = tester.widget<Text>(
|
||||
find.byKey(const Key('assistant-conversation-title')),
|
||||
);
|
||||
expect(titleBefore.data, '默认任务');
|
||||
|
||||
await tester.tap(find.byKey(const Key('assistant-new-task-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final titleAfter = tester.widget<Text>(
|
||||
find.byKey(const Key('assistant-conversation-title')),
|
||||
);
|
||||
expect(titleAfter.data, '新对话');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('AssistantPage keeps draft task visible until archived', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
@ -15,20 +42,92 @@ void main() {
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget);
|
||||
|
||||
final titleBefore = tester.widget<Text>(
|
||||
find.byKey(const Key('assistant-conversation-title')),
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget.key is ValueKey<String> &&
|
||||
(widget.key as ValueKey<String>).value.startsWith(
|
||||
'assistant-task-item-',
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(titleBefore.data, '默认任务');
|
||||
|
||||
await tester.tap(find.byKey(const Key('assistant-new-task-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final titleAfter = tester.widget<Text>(
|
||||
find.byKey(const Key('assistant-conversation-title')),
|
||||
await controller.refreshSessions();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget.key is ValueKey<String> &&
|
||||
(widget.key as ValueKey<String>).value.startsWith(
|
||||
'assistant-task-item-',
|
||||
),
|
||||
),
|
||||
findsNWidgets(2),
|
||||
);
|
||||
expect(titleAfter.data, '新对话');
|
||||
|
||||
final archiveButton = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget.key is ValueKey<String> &&
|
||||
(widget.key as ValueKey<String>).value.startsWith(
|
||||
'assistant-task-archive-draft:',
|
||||
),
|
||||
);
|
||||
expect(archiveButton, findsOneWidget);
|
||||
|
||||
await tester.tap(archiveButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget.key is ValueKey<String> &&
|
||||
(widget.key as ValueKey<String>).value.startsWith(
|
||||
'assistant-task-item-',
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('AssistantPage can switch unified side pane tabs and collapse', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: AssistantPage(
|
||||
controller: controller,
|
||||
onOpenDetail: (_) {},
|
||||
navigationPanelBuilder: (_) => const ColoredBox(
|
||||
key: Key('assistant-nav-panel-probe'),
|
||||
color: Colors.red,
|
||||
),
|
||||
showStandaloneTaskRail: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('assistant-side-pane')), findsOneWidget);
|
||||
expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget);
|
||||
expect(find.byKey(const Key('assistant-nav-panel-probe')), findsNothing);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const Key('assistant-side-pane-tab-navigation')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('assistant-nav-panel-probe')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('assistant-side-pane-toggle')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('assistant-nav-panel-probe')), findsNothing);
|
||||
expect(find.byKey(const Key('assistant-side-pane')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('AssistantPage narrow layout keeps existing single-pane flow', (
|
||||
@ -43,7 +142,10 @@ void main() {
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('assistant-task-rail')), findsNothing);
|
||||
expect(find.byKey(const Key('assistant-conversation-title')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-conversation-title')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('AssistantPage offline submit control opens gateway dialog', (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user