refactor: unify global left workspace sidebar

This commit is contained in:
Haitao Pan 2026-03-30 10:14:35 +08:00
parent 8b49857edb
commit beab07f5ce
7 changed files with 975 additions and 259 deletions

View File

@ -5,7 +5,6 @@ import '../features/mobile/mobile_shell.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../theme/app_palette.dart';
import '../theme/app_theme.dart';
import '../widgets/detail_drawer.dart';
import '../widgets/pane_resize_handle.dart';
import '../widgets/sidebar_navigation.dart';
@ -25,6 +24,7 @@ class _AppShellState extends State<AppShell> {
static const _sidebarMinWidth = 56.0;
static const _sidebarViewportPadding = 72.0;
static const _mainContentMinWidth = 640.0;
static const _sidebarExpandedBaseWidth = 336.0;
double? _sidebarExpandedWidth;
static const _mobileDestinations = [
@ -45,10 +45,50 @@ class _AppShellState extends State<AppShell> {
}
double _defaultSidebarWidth(AppLanguage language, double viewportWidth) {
final baseWidth = language == AppLanguage.zh
? AppSizes.sidebarExpandedWidthZh
: AppSizes.sidebarExpandedWidthEn;
return _clampSidebarWidth(baseWidth, viewportWidth);
return _clampSidebarWidth(_sidebarExpandedBaseWidth, viewportWidth);
}
List<SidebarTaskItem> _buildSidebarTaskItems(AppController controller) {
final currentSessionKey = controller.currentSessionKey.trim().isEmpty
? 'main'
: controller.currentSessionKey.trim();
return controller.assistantSessions.map((session) {
final sessionKey = session.key.trim().isEmpty ? 'main' : session.key.trim();
final preview = session.lastMessagePreview?.trim() ?? '';
return SidebarTaskItem(
sessionKey: sessionKey,
title: session.label.trim().isEmpty
? appText('新对话', 'New conversation')
: session.label.trim(),
preview: preview,
updatedAtMs: session.updatedAtMs,
executionTarget: controller.assistantExecutionTargetForSession(sessionKey),
isCurrent: sessionKey == currentSessionKey,
pending: controller.assistantSessionHasPendingRun(sessionKey),
draft: sessionKey.startsWith('draft:'),
);
}).toList(growable: false);
}
Future<void> _createSidebarConversation(AppController controller) async {
final sessionKey = 'draft:${DateTime.now().millisecondsSinceEpoch}';
controller.initializeAssistantThreadContext(
sessionKey,
title: appText('新对话', 'New conversation'),
executionTarget: controller.currentAssistantExecutionTarget,
messageViewMode: controller.currentAssistantMessageViewMode,
singleAgentProvider: controller.currentSingleAgentProvider,
);
controller.navigateTo(WorkspaceDestination.assistant);
await controller.switchSession(sessionKey);
}
void _toggleSidebarVisibility(AppController controller) {
controller.setSidebarState(
controller.sidebarState == AppSidebarState.hidden
? AppSidebarState.expanded
: AppSidebarState.hidden,
);
}
@override
@ -74,6 +114,7 @@ class _AppShellState extends State<AppShell> {
final uiFeatures = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final sidebarTaskItems = _buildSidebarTaskItems(controller);
final expandedSidebarWidth = _clampSidebarWidth(
_sidebarExpandedWidth ??
_defaultSidebarWidth(
@ -234,9 +275,10 @@ class _AppShellState extends State<AppShell> {
controller.navigateTo(destination);
},
onToggleLanguage: controller.toggleAppLanguage,
onCycleSidebarState: controller.cycleSidebarState,
onExpandFromCollapsed: () => controller
.setSidebarState(AppSidebarState.expanded),
onCycleSidebarState: () =>
_toggleSidebarVisibility(controller),
onExpandFromCollapsed: () =>
_toggleSidebarVisibility(controller),
onOpenHome: controller.navigateHome,
onOpenAccount: () => controller.navigateTo(
WorkspaceDestination.account,
@ -280,6 +322,26 @@ class _AppShellState extends State<AppShell> {
uiFeatures.availableSettingsTabs,
onSettingsTabChanged: (tab) =>
controller.openSettings(tab: tab),
taskItems: sidebarTaskItems,
assistantSkillCount:
controller.currentAssistantSkillCount,
onRefreshTasks: controller.refreshSessions,
onCreateTask: () =>
_createSidebarConversation(controller),
onSelectTask: (sessionKey) async {
controller.navigateTo(WorkspaceDestination.assistant);
await controller.switchSession(sessionKey);
},
onArchiveTask: (sessionKey) =>
controller.saveAssistantTaskArchived(
sessionKey,
true,
),
onRenameTask: (sessionKey, title) =>
controller.saveAssistantTaskTitle(
sessionKey,
title,
),
),
if (sidebarState == AppSidebarState.expanded)
PaneResizeHandle(
@ -332,13 +394,10 @@ class _AppShellState extends State<AppShell> {
),
if (!showSidebar)
Positioned(
left: 0,
top: 8,
bottom: 0,
left: 8,
bottom: 8,
child: _SidebarRevealRail(
onExpand: () => controller.setSidebarState(
AppSidebarState.expanded,
),
onExpand: () => _toggleSidebarVisibility(controller),
),
),
],
@ -398,23 +457,18 @@ class _SidebarRevealRailState extends State<_SidebarRevealRail> {
onTap: widget.onExpand,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: _hovered ? 22 : 10,
width: _hovered ? 40 : 32,
height: _hovered ? 40 : 32,
decoration: BoxDecoration(
color: _hovered ? palette.surfacePrimary : Colors.transparent,
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(14),
),
border: Border.all(
color: _hovered ? palette.strokeSoft : Colors.transparent,
),
color: _hovered ? palette.surfacePrimary : palette.chromeSurface,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: palette.strokeSoft),
),
child: Icon(
Icons.keyboard_double_arrow_right_rounded,
size: 18,
color: palette.textSecondary,
),
child: _hovered
? Icon(
Icons.chevron_right_rounded,
size: 16,
color: palette.textMuted,
)
: null,
),
),
),

View File

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../theme/app_palette.dart';
import '../theme/app_theme.dart';
import '../web/web_assistant_page.dart';
import '../web/web_settings_page.dart';
import '../web/web_workspace_pages.dart';
@ -25,6 +24,7 @@ class _AppShellState extends State<AppShell> {
static const _sidebarMinWidth = 56.0;
static const _sidebarViewportPadding = 72.0;
static const _mainContentMinWidth = 760.0;
static const _sidebarExpandedBaseWidth = 336.0;
AppSidebarState _sidebarState = AppSidebarState.expanded;
double? _sidebarExpandedWidth;
@ -39,22 +39,34 @@ class _AppShellState extends State<AppShell> {
}
double _defaultSidebarWidth(AppLanguage language, double viewportWidth) {
final baseWidth = language == AppLanguage.zh
? AppSizes.sidebarExpandedWidthZh
: AppSizes.sidebarExpandedWidthEn;
return _clampSidebarWidth(baseWidth, viewportWidth);
return _clampSidebarWidth(_sidebarExpandedBaseWidth, viewportWidth);
}
void _cycleSidebarState() {
void _toggleSidebarVisibility() {
setState(() {
_sidebarState = switch (_sidebarState) {
AppSidebarState.expanded => AppSidebarState.collapsed,
AppSidebarState.collapsed => AppSidebarState.hidden,
AppSidebarState.hidden => AppSidebarState.expanded,
};
_sidebarState = _sidebarState == AppSidebarState.hidden
? AppSidebarState.expanded
: AppSidebarState.hidden;
});
}
List<SidebarTaskItem> _buildSidebarTaskItems(AppController controller) {
return controller.conversations
.map(
(item) => SidebarTaskItem(
sessionKey: item.sessionKey,
title: item.title,
preview: item.preview,
updatedAtMs: item.updatedAtMs,
executionTarget: item.executionTarget,
isCurrent: item.current,
pending: item.pending,
draft: item.sessionKey.startsWith('draft:'),
),
)
.toList(growable: false);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@ -86,6 +98,7 @@ class _AppShellState extends State<AppShell> {
child: LayoutBuilder(
builder: (context, constraints) {
final isMobile = constraints.maxWidth < 900;
final sidebarTaskItems = _buildSidebarTaskItems(controller);
final expandedSidebarWidth = _clampSidebarWidth(
_sidebarExpandedWidth ??
_defaultSidebarWidth(
@ -143,88 +156,127 @@ class _AppShellState extends State<AppShell> {
);
}
return Row(
return Stack(
children: [
if (_sidebarState != AppSidebarState.hidden)
SidebarNavigation(
currentSection: currentDestination,
sidebarState: _sidebarState,
appLanguage: controller.appLanguage,
themeMode: controller.themeMode,
onSectionChanged: (destination) {
if (destination == WorkspaceDestination.settings) {
controller.openSettings(tab: SettingsTab.general);
return;
}
controller.navigateTo(destination);
},
onToggleLanguage: controller.toggleAppLanguage,
onCycleSidebarState: _cycleSidebarState,
onExpandFromCollapsed: () {
setState(() {
_sidebarState = AppSidebarState.expanded;
});
},
onOpenHome: controller.navigateHome,
onOpenAccount: () {},
onOpenThemeToggle: () => controller.setThemeMode(
controller.themeMode == ThemeMode.dark
? ThemeMode.light
: ThemeMode.dark,
Row(
children: [
if (_sidebarState != AppSidebarState.hidden)
SidebarNavigation(
currentSection: currentDestination,
sidebarState: _sidebarState,
appLanguage: controller.appLanguage,
themeMode: controller.themeMode,
onSectionChanged: (destination) {
if (destination ==
WorkspaceDestination.settings) {
controller.openSettings(
tab: SettingsTab.general,
);
return;
}
controller.navigateTo(destination);
},
onToggleLanguage: controller.toggleAppLanguage,
onCycleSidebarState: _toggleSidebarVisibility,
onExpandFromCollapsed: _toggleSidebarVisibility,
onOpenHome: controller.navigateHome,
onOpenAccount: () {},
onOpenThemeToggle: () => controller.setThemeMode(
controller.themeMode == ThemeMode.dark
? ThemeMode.light
: ThemeMode.dark,
),
accountName:
controller.settings.accountUsername
.trim()
.isNotEmpty
? controller.settings.accountUsername
: appText('Web 操作员', 'Web operator'),
accountSubtitle:
controller.settings.accountWorkspace
.trim()
.isNotEmpty
? controller.settings.accountWorkspace
: appText('Web 工作区', 'Web workspace'),
accountWorkspaceFollowed:
controller.settings.accountWorkspaceFollowed,
onToggleAccountWorkspaceFollowed:
controller.toggleAccountWorkspaceFollowed,
expandedWidthOverride:
_sidebarState == AppSidebarState.expanded
? expandedSidebarWidth
: null,
favoriteDestinations: controller
.assistantNavigationDestinations
.toSet(),
onToggleFavorite:
controller.toggleAssistantNavigationDestination,
availableDestinations:
controller.capabilities.allowedDestinations,
currentSettingsTab: controller.settingsTab,
availableSettingsTabs:
uiFeatures.availableSettingsTabs,
onSettingsTabChanged: (tab) =>
controller.openSettings(tab: tab),
taskItems: sidebarTaskItems,
assistantSkillCount:
controller.currentAssistantSkillCount,
onRefreshTasks: controller.refreshSessions,
onCreateTask: () async {
await controller.createConversation(
target: controller.assistantExecutionTarget,
);
controller.navigateTo(
WorkspaceDestination.assistant,
);
},
onSelectTask: (sessionKey) async {
controller.navigateTo(
WorkspaceDestination.assistant,
);
await controller.switchConversation(sessionKey);
},
onArchiveTask: (sessionKey) =>
controller.saveAssistantTaskArchived(
sessionKey,
true,
),
onRenameTask: (sessionKey, title) =>
controller.saveAssistantTaskTitle(
sessionKey,
title,
),
),
if (_sidebarState == AppSidebarState.expanded)
PaneResizeHandle(
axis: Axis.horizontal,
onDelta: (delta) {
setState(() {
_sidebarExpandedWidth = _clampSidebarWidth(
expandedSidebarWidth + delta,
constraints.maxWidth,
);
});
},
),
Expanded(
child: _WebShellBody(
child: _buildPage(
controller,
destination: currentDestination,
),
),
),
accountName:
controller.settings.accountUsername
.trim()
.isNotEmpty
? controller.settings.accountUsername
: appText('Web 操作员', 'Web operator'),
accountSubtitle:
controller.settings.accountWorkspace
.trim()
.isNotEmpty
? controller.settings.accountWorkspace
: appText('Web 工作区', 'Web workspace'),
accountWorkspaceFollowed:
controller.settings.accountWorkspaceFollowed,
onToggleAccountWorkspaceFollowed:
controller.toggleAccountWorkspaceFollowed,
expandedWidthOverride:
_sidebarState == AppSidebarState.expanded
? expandedSidebarWidth
: null,
favoriteDestinations: controller
.assistantNavigationDestinations
.toSet(),
onToggleFavorite:
controller.toggleAssistantNavigationDestination,
availableDestinations:
controller.capabilities.allowedDestinations,
currentSettingsTab: controller.settingsTab,
availableSettingsTabs:
uiFeatures.availableSettingsTabs,
onSettingsTabChanged: (tab) =>
controller.openSettings(tab: tab),
),
if (_sidebarState == AppSidebarState.expanded)
PaneResizeHandle(
axis: Axis.horizontal,
onDelta: (delta) {
setState(() {
_sidebarExpandedWidth = _clampSidebarWidth(
expandedSidebarWidth + delta,
constraints.maxWidth,
);
});
},
),
Expanded(
child: _WebShellBody(
child: _buildPage(
controller,
destination: currentDestination,
),
),
],
),
if (_sidebarState == AppSidebarState.hidden)
Positioned(
left: 8,
bottom: 8,
child: _SidebarRevealRail(
onExpand: _toggleSidebarVisibility,
),
),
],
);
},
@ -256,6 +308,49 @@ class _AppShellState extends State<AppShell> {
}
}
class _SidebarRevealRail extends StatefulWidget {
const _SidebarRevealRail({required this.onExpand});
final VoidCallback onExpand;
@override
State<_SidebarRevealRail> createState() => _SidebarRevealRailState();
}
class _SidebarRevealRailState extends State<_SidebarRevealRail> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Tooltip(
message: appText('展开左栏', 'Expand sidebar'),
child: GestureDetector(
onTap: widget.onExpand,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: _hovered ? 40 : 32,
height: _hovered ? 40 : 32,
decoration: BoxDecoration(
color: _hovered ? palette.surfacePrimary : palette.chromeSurface,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: palette.strokeSoft),
),
child: Icon(
Icons.keyboard_double_arrow_right_rounded,
size: 18,
color: palette.textSecondary,
),
),
),
),
);
}
}
class _WebShellBody extends StatelessWidget {
const _WebShellBody({required this.child});

View File

@ -38,6 +38,7 @@ workspacePageSpecsInternal = <WorkspaceDestination, WorkspacePageSpec>{
desktopBuilder: (controller, onOpenDetail) => AssistantPage(
controller: controller,
onOpenDetail: onOpenDetail,
showStandaloneTaskRail: false,
),
mobileBuilder: (controller, onOpenDetail) => AssistantPage(
controller: controller,

View File

@ -19,8 +19,6 @@ import 'web_assistant_page_chrome.dart';
import 'web_assistant_page_workspace.dart';
import 'web_assistant_page_helpers.dart';
const double webAssistantSidePaneMinWidthInternal = 304;
const double webAssistantSidePaneMaxWidthInternal = 420;
const double webAssistantMainWorkspaceMinWidthInternal = 700;
const double webAssistantComposerMinHeightInternal = 164;
const double webAssistantConversationMinHeightInternal = 200;
@ -49,7 +47,6 @@ class WebAssistantPageStateInternal extends State<WebAssistantPage> {
AssistantPermissionLevel.defaultAccess;
bool useMultiAgentInternal = false;
bool workspaceChromeCollapsedInternal = false;
double sidePaneWidthInternal = 344;
bool artifactPaneCollapsedInternal = true;
double artifactPaneWidthInternal =
webAssistantArtifactPaneDefaultWidthInternal;
@ -88,19 +85,6 @@ class WebAssistantPageStateInternal extends State<WebAssistantPage> {
return DesktopWorkspaceScaffold(
child: LayoutBuilder(
builder: (context, constraints) {
final maxSidePaneWidth = math.min(
webAssistantSidePaneMaxWidthInternal,
math.max(
webAssistantSidePaneMinWidthInternal,
constraints.maxWidth -
webAssistantMainWorkspaceMinWidthInternal,
),
);
final sidePaneWidth = sidePaneWidthInternal.clamp(
webAssistantSidePaneMinWidthInternal,
maxSidePaneWidth,
);
return Column(
children: [
AssistantWorkspaceChromeInternal(
@ -115,113 +99,38 @@ class WebAssistantPageStateInternal extends State<WebAssistantPage> {
),
const SizedBox(height: 8),
Expanded(
child: Row(
children: [
SizedBox(
width: sidePaneWidth,
child: AssistantTaskPaneInternal(
controller: controller,
query: queryInternal,
searchController: searchControllerInternal,
onQueryChanged: (value) {
setState(
() =>
queryInternal = value.trim().toLowerCase(),
);
},
onClearQuery: () {
searchControllerInternal.clear();
setState(() => queryInternal = '');
},
showSingle: controller
.featuresFor(UiFeaturePlatform.web)
.supportsDirectAi,
showLocal: controller
.featuresFor(UiFeaturePlatform.web)
.supportsLocalGateway,
showRemote: controller
.featuresFor(UiFeaturePlatform.web)
.supportsRelayGateway,
single: filterConversationsInternal(
controller.conversationsForTarget(
AssistantExecutionTarget.singleAgent,
),
queryInternal,
),
local: filterConversationsInternal(
controller.conversationsForTarget(
AssistantExecutionTarget.local,
),
queryInternal,
),
remote: filterConversationsInternal(
controller.conversationsForTarget(
AssistantExecutionTarget.remote,
),
queryInternal,
),
onRename: renameConversationInternal,
onArchive: (sessionKey) => controller
.saveAssistantTaskArchived(sessionKey, true),
onOpenActions: openConversationActionsInternal,
),
),
SizedBox(
width: 8,
child: PaneResizeHandle(
axis: Axis.horizontal,
onDelta: (delta) {
setState(() {
sidePaneWidthInternal =
(sidePaneWidthInternal + delta)
.clamp(
webAssistantSidePaneMinWidthInternal,
maxSidePaneWidth,
)
.toDouble();
});
},
),
),
Expanded(
child: buildWorkspaceWithArtifactsInternal(
controller: controller,
child: ConversationWorkspaceInternal(
controller: controller,
scrollController: scrollControllerInternal,
inputController: inputControllerInternal,
currentMessages: currentMessages,
connectionState: connectionState,
thinkingLevel: thinkingLevelInternal,
permissionLevel: permissionLevelInternal,
useMultiAgent: useMultiAgentInternal,
attachments: attachmentsInternal,
composerHeight: composerHeightInternal,
onComposerHeightChanged: (value) {
setState(() => composerHeightInternal = value);
},
onThinkingChanged: (value) {
setState(() => thinkingLevelInternal = value);
},
onPermissionChanged: (value) {
setState(() => permissionLevelInternal = value);
},
onToggleMultiAgent: (value) {
setState(() => useMultiAgentInternal = value);
},
onAddAttachment: pickAttachmentsInternal,
onRemoveAttachment: (index) {
setState(
() => attachmentsInternal.removeAt(index),
);
},
onOpenSessionSettings:
openSessionSettingsInternal,
onSubmit: submitPromptInternal,
),
),
),
],
child: buildWorkspaceWithArtifactsInternal(
controller: controller,
child: ConversationWorkspaceInternal(
controller: controller,
scrollController: scrollControllerInternal,
inputController: inputControllerInternal,
currentMessages: currentMessages,
connectionState: connectionState,
thinkingLevel: thinkingLevelInternal,
permissionLevel: permissionLevelInternal,
useMultiAgent: useMultiAgentInternal,
attachments: attachmentsInternal,
composerHeight: composerHeightInternal,
onComposerHeightChanged: (value) {
setState(() => composerHeightInternal = value);
},
onThinkingChanged: (value) {
setState(() => thinkingLevelInternal = value);
},
onPermissionChanged: (value) {
setState(() => permissionLevelInternal = value);
},
onToggleMultiAgent: (value) {
setState(() => useMultiAgentInternal = value);
},
onAddAttachment: pickAttachmentsInternal,
onRemoveAttachment: (index) {
setState(() => attachmentsInternal.removeAt(index));
},
onOpenSessionSettings: openSessionSettingsInternal,
onSubmit: submitPromptInternal,
),
),
),
],

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/runtime_models.dart';
import '../theme/app_palette.dart';
import '../theme/app_theme.dart';
import 'chrome_quick_action_buttons.dart';
@ -34,6 +35,13 @@ class SidebarNavigation extends StatelessWidget {
this.currentSettingsTab,
this.availableSettingsTabs = const <SettingsTab>[],
this.onSettingsTabChanged,
this.taskItems = const <SidebarTaskItem>[],
this.assistantSkillCount = 0,
this.onRefreshTasks,
this.onCreateTask,
this.onSelectTask,
this.onArchiveTask,
this.onRenameTask,
});
final WorkspaceDestination currentSection;
@ -61,6 +69,13 @@ class SidebarNavigation extends StatelessWidget {
final SettingsTab? currentSettingsTab;
final List<SettingsTab> availableSettingsTabs;
final ValueChanged<SettingsTab>? onSettingsTabChanged;
final List<SidebarTaskItem> taskItems;
final int assistantSkillCount;
final Future<void> Function()? onRefreshTasks;
final Future<void> Function()? onCreateTask;
final Future<void> Function(String sessionKey)? onSelectTask;
final Future<void> Function(String sessionKey)? onArchiveTask;
final Future<void> Function(String sessionKey, String title)? onRenameTask;
static const _primarySections = <WorkspaceDestination>[
WorkspaceDestination.assistant,
@ -85,6 +100,7 @@ class SidebarNavigation extends StatelessWidget {
final palette = context.palette;
final isExpanded = sidebarState == AppSidebarState.expanded;
final isCollapsed = sidebarState == AppSidebarState.collapsed;
final showTaskSection = !isCollapsed;
final primarySections = _filterSections(_primarySections);
final workspaceSections = _filterSections(_workspaceSections);
final toolSections = _filterSections(_toolSections);
@ -110,11 +126,7 @@ class SidebarNavigation extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SidebarHeader(
isCollapsed: !isExpanded,
onTap: isCollapsed ? onExpandFromCollapsed : null,
),
const SizedBox(height: 6),
const SizedBox(height: 4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -125,6 +137,29 @@ class SidebarNavigation extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showTaskSection)
SidebarTaskSection(
items: taskItems,
skillCount: assistantSkillCount,
onRefreshTasks: onRefreshTasks,
onCreateTask: onCreateTask,
onSelectTask: onSelectTask,
onArchiveTask: onArchiveTask,
onRenameTask: onRenameTask,
),
if (showTaskSection &&
(primarySections.isNotEmpty ||
workspaceSections.isNotEmpty ||
toolSections.isNotEmpty))
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Container(
height: 1,
color: palette.chromeStroke.withValues(
alpha: 0.9,
),
),
),
if (primarySections.isNotEmpty)
_SidebarSectionGroup(
sections: primarySections,
@ -225,6 +260,595 @@ class SidebarNavigation extends StatelessWidget {
}
}
class SidebarTaskItem {
const SidebarTaskItem({
required this.sessionKey,
required this.title,
required this.preview,
required this.updatedAtMs,
required this.executionTarget,
required this.isCurrent,
required this.pending,
this.draft = false,
});
final String sessionKey;
final String title;
final String preview;
final double? updatedAtMs;
final AssistantExecutionTarget executionTarget;
final bool isCurrent;
final bool pending;
final bool draft;
}
class SidebarTaskSection extends StatefulWidget {
const SidebarTaskSection({
super.key,
required this.items,
required this.skillCount,
this.onRefreshTasks,
this.onCreateTask,
this.onSelectTask,
this.onArchiveTask,
this.onRenameTask,
});
final List<SidebarTaskItem> items;
final int skillCount;
final Future<void> Function()? onRefreshTasks;
final Future<void> Function()? onCreateTask;
final Future<void> Function(String sessionKey)? onSelectTask;
final Future<void> Function(String sessionKey)? onArchiveTask;
final Future<void> Function(String sessionKey, String title)? onRenameTask;
@override
State<SidebarTaskSection> createState() => _SidebarTaskSectionState();
}
class _SidebarTaskSectionState extends State<SidebarTaskSection> {
final TextEditingController _searchController = TextEditingController();
final Set<AssistantExecutionTarget> _expandedTargets =
<AssistantExecutionTarget>{};
String _query = '';
@override
void initState() {
super.initState();
_syncExpandedTargets();
}
@override
void didUpdateWidget(covariant SidebarTaskSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.items != widget.items) {
_syncExpandedTargets();
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final filteredItems = _filteredItems();
final groups = _groupedItems(filteredItems);
final runningCount = filteredItems.where((item) => item.pending).length;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
child: TextField(
key: const Key('workspace-sidebar-task-search'),
controller: _searchController,
onChanged: (value) {
setState(() {
_query = value.trim().toLowerCase();
});
},
decoration: InputDecoration(
hintText: appText('搜索任务', 'Search tasks'),
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_query.isNotEmpty)
IconButton(
tooltip: appText('清除搜索', 'Clear search'),
onPressed: () {
_searchController.clear();
setState(() {
_query = '';
});
},
icon: const Icon(Icons.close_rounded),
),
if (widget.onRefreshTasks != null)
IconButton(
key: const Key('workspace-sidebar-task-refresh'),
tooltip: appText('刷新任务', 'Refresh tasks'),
onPressed: () async {
await widget.onRefreshTasks!();
},
icon: const Icon(Icons.refresh_rounded),
),
],
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
child: FilledButton.tonalIcon(
key: const Key('workspace-sidebar-new-task-button'),
onPressed: widget.onCreateTask == null
? null
: () async {
await widget.onCreateTask!();
},
icon: const Icon(Icons.edit_note_rounded),
label: Text(appText('新对话', 'New conversation')),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 40),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
_SidebarMetaPill(
icon: Icons.play_circle_outline_rounded,
label: '${appText('运行中', 'Running')} $runningCount',
),
_SidebarMetaPill(
icon: Icons.forum_outlined,
label: '${appText('当前', 'Current')} ${filteredItems.length}',
),
_SidebarMetaPill(
icon: Icons.auto_awesome_rounded,
label: '${appText('技能', 'Skills')} ${widget.skillCount}',
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 6),
child: Row(
children: [
Text(appText('任务列表', 'Task list'), style: theme.textTheme.titleSmall),
const SizedBox(width: 6),
Text(
'${filteredItems.length}',
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
],
),
),
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 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),
],
],
);
}
List<SidebarTaskItem> _filteredItems() {
if (_query.isEmpty) {
return widget.items;
}
return widget.items.where((item) {
final haystack = '${item.title}\n${item.preview}\n${item.sessionKey}'
.toLowerCase();
return haystack.contains(_query);
}).toList(growable: false);
}
List<_SidebarTaskGroup> _groupedItems(List<SidebarTaskItem> items) {
final grouped = <AssistantExecutionTarget, List<SidebarTaskItem>>{
for (final target in AssistantExecutionTarget.values)
target: <SidebarTaskItem>[],
};
for (final item in items) {
grouped[item.executionTarget]!.add(item);
}
return AssistantExecutionTarget.values
.map(
(target) => _SidebarTaskGroup(
executionTarget: target,
items: grouped[target]!,
),
)
.toList(growable: false);
}
Future<String?> _promptRenameTask(
BuildContext context,
String currentTitle,
) async {
final input = TextEditingController(text: currentTitle);
final result = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(appText('重命名任务', 'Rename task')),
content: TextField(
key: const Key('workspace-sidebar-task-rename-input'),
controller: input,
autofocus: true,
decoration: InputDecoration(
labelText: appText('任务名称', 'Task name'),
hintText: appText('留空后恢复默认名称', 'Leave empty to restore default'),
),
onSubmitted: (value) => Navigator.of(context).pop(value.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(appText('取消', 'Cancel')),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(input.text.trim()),
child: Text(appText('保存', 'Save')),
),
],
),
);
input.dispose();
return result;
}
void _syncExpandedTargets() {
if (_expandedTargets.isNotEmpty) {
return;
}
_expandedTargets.addAll(AssistantExecutionTarget.values);
}
}
class _SidebarTaskGroup {
const _SidebarTaskGroup({
required this.executionTarget,
required this.items,
});
final AssistantExecutionTarget executionTarget;
final List<SidebarTaskItem> items;
}
class _SidebarMetaPill extends StatelessWidget {
const _SidebarMetaPill({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: palette.textSecondary),
const SizedBox(width: 6),
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: palette.textSecondary,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
class _SidebarTaskGroupHeader extends StatelessWidget {
const _SidebarTaskGroupHeader({
required this.executionTarget,
required this.count,
required this.expanded,
required this.onTap,
});
final AssistantExecutionTarget executionTarget;
final int count;
final bool expanded;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey<String>('workspace-sidebar-task-group-${executionTarget.name}'),
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
child: Row(
children: [
Icon(
expanded
? Icons.keyboard_arrow_down_rounded
: Icons.keyboard_arrow_right_rounded,
size: 16,
color: palette.textMuted,
),
const SizedBox(width: 4),
Icon(
_sidebarTaskTargetIcon(executionTarget),
size: 14,
color: palette.textMuted,
),
const SizedBox(width: 6),
Expanded(
child: Text(
executionTarget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium?.copyWith(
color: palette.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 6),
Text(
'$count',
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
],
),
),
),
);
}
}
class _SidebarTaskTile extends StatelessWidget {
const _SidebarTaskTile({
required this.item,
this.onTap,
this.onArchive,
this.onRename,
});
final SidebarTaskItem item;
final Future<void> Function()? onTap;
final Future<void> Function()? onArchive;
final Future<void> Function()? onRename;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Material(
color: item.isCurrent ? palette.surfacePrimary : Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
key: ValueKey<String>('workspace-sidebar-task-item-${item.sessionKey}'),
borderRadius: BorderRadius.circular(8),
onTap: onTap == null
? null
: () async {
await onTap!();
},
onLongPress: onRename == null
? null
: () async {
await onRename!();
},
onSecondaryTap: onRename == null
? null
: () async {
await onRename!();
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
color: item.isCurrent ? palette.surfaceSecondary : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: item.isCurrent ? palette.strokeSoft : Colors.transparent,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: item.pending
? palette.accentMuted.withValues(alpha: 0.88)
: palette.surfacePrimary,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
item.draft
? Icons.edit_note_rounded
: item.pending
? Icons.play_arrow_rounded
: Icons.task_alt_rounded,
size: 15,
color: item.pending ? palette.accent : palette.textSecondary,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: item.isCurrent
? FontWeight.w700
: FontWeight.w600,
),
),
if (item.preview.trim().isNotEmpty) ...[
const SizedBox(height: 2),
Text(
item.preview.trim(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
],
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_sidebarTaskUpdatedAtLabel(item.updatedAtMs),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
if (onArchive != null)
IconButton(
key: ValueKey<String>(
'workspace-sidebar-task-archive-${item.sessionKey}',
),
tooltip: appText('归档任务', 'Archive task'),
visualDensity: VisualDensity.compact,
splashRadius: 12,
onPressed: () async {
await onArchive!();
},
icon: Icon(
Icons.archive_outlined,
size: 18,
color: palette.textMuted,
),
),
],
),
],
),
),
),
);
}
}
String _sidebarTaskUpdatedAtLabel(double? updatedAtMs) {
if (updatedAtMs == null) {
return '';
}
final timestamp = DateTime.fromMillisecondsSinceEpoch(updatedAtMs.round());
final now = DateTime.now();
final delta = now.difference(timestamp);
if (delta.inMinutes < 1) {
return appText('刚刚', 'Just now');
}
if (delta.inHours < 1) {
return appText('${delta.inMinutes} 分钟前', '${delta.inMinutes}m ago');
}
if (delta.inDays < 1) {
return appText('${delta.inHours} 小时前', '${delta.inHours}h ago');
}
if (delta.inDays < 7) {
return appText('${delta.inDays} 天前', '${delta.inDays}d ago');
}
return '${timestamp.month}/${timestamp.day}';
}
IconData _sidebarTaskTargetIcon(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.singleAgent => Icons.hub_outlined,
AssistantExecutionTarget.local => Icons.computer_outlined,
AssistantExecutionTarget.remote => Icons.cloud_outlined,
};
}
class SidebarHeader extends StatelessWidget {
const SidebarHeader({super.key, required this.isCollapsed, this.onTap});
@ -857,9 +1481,9 @@ class SidebarFooter extends StatelessWidget {
IconData _sidebarStateIcon(AppSidebarState state) {
return switch (state) {
AppSidebarState.expanded => Icons.view_sidebar_rounded,
AppSidebarState.collapsed => Icons.menu_rounded,
AppSidebarState.hidden => Icons.view_sidebar_rounded,
AppSidebarState.expanded => Icons.keyboard_double_arrow_left_rounded,
AppSidebarState.collapsed => Icons.keyboard_double_arrow_right_rounded,
AppSidebarState.hidden => Icons.keyboard_double_arrow_right_rounded,
};
}

View File

@ -22,7 +22,15 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('助手'), findsWidgets);
expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget);
expect(find.byKey(const Key('assistant-task-rail')), findsNothing);
expect(
find.byKey(const Key('workspace-sidebar-task-search')),
findsOneWidget,
);
expect(
find.byKey(const Key('workspace-sidebar-new-task-button')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-workspace-chrome-toggle')),
findsOneWidget,
@ -88,7 +96,8 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(SidebarNavigation), findsOneWidget);
await tester.tap(find.text('自动化'));
await tester.ensureVisible(find.text('自动化'));
await tester.tap(find.text('自动化').hitTestable());
await tester.pumpAndSettle();
expect(find.text('任务工作台'), findsOneWidget);

View File

@ -5,8 +5,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/i18n/app_language.dart';
import 'package:xworkmate/models/app_models.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/theme/app_theme.dart';
import 'package:xworkmate/widgets/app_brand_logo.dart';
import 'package:xworkmate/widgets/sidebar_navigation.dart';
void main() {
@ -88,7 +88,8 @@ void main() {
expect(find.text('工具'), findsOneWidget);
expect(find.text('MCP Hub'), findsOneWidget);
await tester.tap(find.text('自动化'));
await tester.ensureVisible(find.text('自动化'));
await tester.tap(find.text('自动化').hitTestable());
await tester.pumpAndSettle();
expect(selected, WorkspaceDestination.tasks);
@ -201,9 +202,10 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('回到 APP首页'), findsOneWidget);
expect(find.text('新对话'), findsNothing);
expect(find.text('新对话'), findsWidgets);
await tester.tap(find.text('回到 APP首页'));
await tester.ensureVisible(find.text('回到 APP首页'));
await tester.tap(find.text('回到 APP首页').hitTestable());
await tester.pumpAndSettle();
expect(homeOpened, 1);
@ -269,7 +271,7 @@ void main() {
expect(changedTabs, <SettingsTab>[SettingsTab.gateway]);
});
testWidgets('SidebarNavigation header uses chevron instead of brand logo', (
testWidgets('SidebarNavigation merges task controls into the global left bar', (
WidgetTester tester,
) async {
await tester.pumpWidget(
@ -291,13 +293,35 @@ void main() {
accountName: 'Tester',
accountSubtitle: 'Workspace',
onToggleAccountWorkspaceFollowed: () async {},
assistantSkillCount: 3,
taskItems: const <SidebarTaskItem>[
SidebarTaskItem(
sessionKey: 'draft:1',
title: '新的任务',
preview: '等待输入',
updatedAtMs: 1710000000000,
executionTarget: AssistantExecutionTarget.singleAgent,
isCurrent: true,
pending: false,
draft: true,
),
],
),
),
),
);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.chevron_right_rounded), findsOneWidget);
expect(find.byType(AppBrandLogo), findsNothing);
expect(
find.byKey(const Key('workspace-sidebar-task-search')),
findsOneWidget,
);
expect(
find.byKey(const Key('workspace-sidebar-new-task-button')),
findsOneWidget,
);
expect(find.text('任务列表'), findsOneWidget);
expect(find.text('自动化'), findsOneWidget);
expect(find.text('新的任务'), findsOneWidget);
});
}