refactor: unify global left workspace sidebar
This commit is contained in:
parent
8b49857edb
commit
beab07f5ce
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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});
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ workspacePageSpecsInternal = <WorkspaceDestination, WorkspacePageSpec>{
|
||||
desktopBuilder: (controller, onOpenDetail) => AssistantPage(
|
||||
controller: controller,
|
||||
onOpenDetail: onOpenDetail,
|
||||
showStandaloneTaskRail: false,
|
||||
),
|
||||
mobileBuilder: (controller, onOpenDetail) => AssistantPage(
|
||||
controller: controller,
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user