From 8539eab3b7bdc03782c63e540c1fa61ef1049b23 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 30 Mar 2026 08:29:48 +0800 Subject: [PATCH] refactor: dedupe focus panel and move settings nav into sidebar --- ...-settings-assistant-ui-component-matrix.md | 164 +++++ ...-focus-panel-dedupe-implementation-plan.md | 146 ++++ lib/app/app_shell_desktop.dart | 9 + lib/app/app_shell_web.dart | 7 + lib/features/settings/settings_page_core.dart | 46 +- lib/web/web_assistant_page_chrome.dart | 2 +- lib/web/web_focus_panel.dart | 4 +- lib/web/web_focus_panel_core.dart | 347 ---------- lib/web/web_focus_panel_previews.dart | 636 ------------------ lib/web/web_focus_panel_support.dart | 75 --- lib/web/web_settings_page_core.dart | 36 +- lib/widgets/assistant_focus_panel_core.dart | 31 +- .../assistant_focus_panel_previews.dart | 137 ++-- .../assistant_focus_panel_support.dart | 1 - lib/widgets/sidebar_navigation.dart | 239 +++++++ test/features/settings_page_suite.dart | 17 +- test/web/web_ui_browser_test.dart | 12 +- test/widgets/assistant_focus_panel_suite.dart | 3 +- test/widgets/sidebar_navigation_suite.dart | 58 ++ 19 files changed, 748 insertions(+), 1222 deletions(-) create mode 100644 docs/plans/2026-03-29-focus-panel-dedupe-implementation-plan.md delete mode 100644 lib/web/web_focus_panel_core.dart delete mode 100644 lib/web/web_focus_panel_previews.dart delete mode 100644 lib/web/web_focus_panel_support.dart diff --git a/docs/planning/2026-03-29-settings-assistant-ui-component-matrix.md b/docs/planning/2026-03-29-settings-assistant-ui-component-matrix.md index 682ace5c..da51e8b2 100644 --- a/docs/planning/2026-03-29-settings-assistant-ui-component-matrix.md +++ b/docs/planning/2026-03-29-settings-assistant-ui-component-matrix.md @@ -328,3 +328,167 @@ Mobile 当前不是截图 A / B 的直接缩放版,而是“能力对应、布 3. `cross-platform-ui-parity.md` 专门记录 Desktop / Web / Mobile 的同构与差异 +--- + +## 可合并 / 可清理分析 + +在保持三栏布局骨架不变的前提下,当前最值得处理的不是页面骨架,而是页面骨架内部已经重复的“焦点卡片层”和“预览内容层”。 + +### A. 第一优先级:可直接合并复用 + +#### 1. Focus Panel Core + +- Desktop: `lib/widgets/assistant_focus_panel_core.dart` +- Web: `lib/web/web_focus_panel_core.dart` + +这两份文件当前为同构重复实现。 + +判断依据: + +- 文件长度完全一致,均为 `347` 行 +- 代码 diff 主要只剩: + - `app_controller.dart` vs `app_controller_web.dart` + - 相对 import 路径差异 + - widget 命名前缀 `Assistant*` vs `WebAssistant*` + +合并建议: + +- 保留 `lib/widgets/assistant_focus_panel*.dart` 作为唯一实现 +- Web 端直接复用共享 widget +- 删除 Web 侧同构副本,或先退化为兼容导出薄壳 + +#### 2. Focus Panel Previews + +- Desktop: `lib/widgets/assistant_focus_panel_previews.dart` +- Web: `lib/web/web_focus_panel_previews.dart` + +这两份文件当前也为同构重复实现。 + +判断依据: + +- 文件长度完全一致,均为 `636` 行 +- diff 也只剩 controller import 和路径差异 +- `SettingsFocusPreviewInternal`、`LanguageFocusPreviewInternal`、`ThemeFocusPreviewInternal` 等实现一致 + +合并建议: + +- 收敛到 `lib/widgets/assistant_focus_panel_previews.dart` +- Web Assistant 通过共享导出的 `AssistantFocusPanel` 直接复用 + +#### 3. Focus Panel Support + +- Desktop: `lib/widgets/assistant_focus_panel_support.dart` +- Web: `lib/web/web_focus_panel_support.dart` + +这两份 support 文件同样是同构副本,可和 core / previews 一起收口。 + +### B. 第二优先级:可抽公共层,但不建议整页硬合并 + +#### 1. Settings Page Core + +- Desktop: `lib/features/settings/settings_page_core.dart` +- Web: `lib/web/web_settings_page_core.dart` + +不建议直接合成一个页面类。 + +原因: + +- Desktop settings 还承载 `SettingsDetailPage` drill-down、`initialDetail`、`navigationContext` +- Web settings 重点维护浏览器会话持久化、Local/Remote Gateway、External ACP +- 二者状态模型明显不对称 + +建议做法: + +- 抽共享壳层和 section 编排逻辑 +- 保留 Desktop / Web 两个 page container + +#### 2. Settings Page Sections + +- Desktop: `lib/features/settings/settings_page_sections.dart` +- Web: `lib/web/web_settings_page_sections.dart` + +这里适合抽: + +- `buildGlobalApplyBarInternal` +- overview tab 排布 +- section 级 SurfaceCard 组织方式 + +但不适合一步到位强合并所有业务 section。 + +### C. 可标记清理 + +#### 1. Web Focus Panel 重复文件 + +以下文件可以标记为第一批清理对象: + +- `lib/web/web_focus_panel_core.dart` +- `lib/web/web_focus_panel_previews.dart` +- `lib/web/web_focus_panel_support.dart` + +清理前提: + +- Web Assistant 全部切回共享 `lib/widgets/assistant_focus_panel.dart` +- 相关测试与 import 路径同步调整 + +#### 2. 已废弃 iOS 壳别名 + +- `lib/features/mobile/ios_mobile_shell.dart` + +该文件已标注: + +- `@Deprecated('Use MobileShell instead.')` + +功能上只是直接转发到 `MobileShell`,可以标记为后续兼容清理对象。 + +#### 3. 不在这两张图主链路中的 settings detail 旁路 + +以下内容不属于截图 B 的 overview 主链路,建议后续拆出: + +- `buildDetailContentInternal` +- `buildDetailIntroInternal` +- `SettingsDetailPage.*` + +对应文件: + +- `lib/features/settings/settings_page_sections.dart` + +这些应独立成 detail flow 文件,而不是继续和 overview 同居在一个大文件里。 + +#### 4. settings 目录中的大面积冗余 import + +当前以下文件头部都带有较重的 `unused_import` / 互相引用信号: + +- `lib/features/settings/settings_page_core.dart` +- `lib/features/settings/settings_page_sections.dart` +- `lib/features/settings/settings_page_widgets.dart` +- `lib/features/settings/settings_page_gateway_connection.dart` +- `lib/features/settings/settings_page_gateway_llm.dart` +- `lib/features/settings/settings_page_multi_agent.dart` +- `lib/features/settings/settings_page_presentation.dart` +- `lib/features/settings/settings_page_device.dart` + +这些不是“立即删除”对象,但可以明确标记为依赖清理范围。 + +### D. 本轮推荐执行顺序 + +在不动三栏布局骨架前提下,推荐按这个顺序执行: + +1. 合并 Desktop / Web 的 Focus Panel Core / Previews / Support +2. 抽 Settings 页的共享 section 壳层 +3. 拆 Settings detail flow,避免与 overview 混杂 +4. 清理 deprecated mobile 壳与 settings 冗余 imports + +### E. 当前基线测试记录 + +本轮开始前的最小基线如下: + +- `flutter test test/widgets/assistant_focus_panel_suite.dart` + - 结果:通过 +- `flutter test --platform chrome test/web/web_ui_browser_test.dart` + - 结果:失败 + - 当前已知失败点:测试找不到 “集成” tab + +因此后续重构验证需要区分: + +- 共享 Focus Panel 的 widget 层回归 +- Web 端已有基线失败,不能误判为本轮新引入问题 diff --git a/docs/plans/2026-03-29-focus-panel-dedupe-implementation-plan.md b/docs/plans/2026-03-29-focus-panel-dedupe-implementation-plan.md new file mode 100644 index 00000000..00c43444 --- /dev/null +++ b/docs/plans/2026-03-29-focus-panel-dedupe-implementation-plan.md @@ -0,0 +1,146 @@ +# Focus Panel Dedupe Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 在保持 Assistant 三栏布局骨架不变的前提下,收敛 Desktop / Web 重复的 Focus Panel 实现,建立共享前端组件单一来源。 + +**Architecture:** 保留 `lib/widgets/assistant_focus_panel*.dart` 作为唯一实现,Web 端改为直接复用共享 widgets,不再维护同构副本。先处理 Focus Panel Core / Previews / Support,再调整 Web Assistant 侧的 import 和引用,最后更新测试与文档。 + +**Tech Stack:** Flutter, Dart, conditional export `app_controller.dart`, widget tests, browser tests + +--- + +### Task 1: 记录当前实施上下文 + +**Files:** +- Modify: `docs/planning/2026-03-29-settings-assistant-ui-component-matrix.md` +- Create: `docs/plans/2026-03-29-focus-panel-dedupe-implementation-plan.md` + +**Step 1: 追加“可合并 / 可清理分析”** + +把本轮结论写入组件矩阵文档: + +- 可直接合并:Focus Panel Core / Previews / Support +- 可抽公共层:Settings Page Core / Sections +- 可标记清理:Web Focus Panel 副本、deprecated iOS shell、settings detail 旁路 + +**Step 2: 记录基线测试** + +记录: + +- `flutter test test/widgets/assistant_focus_panel_suite.dart` 通过 +- `flutter test --platform chrome test/web/web_ui_browser_test.dart` 当前基线失败,失败点为找不到 “集成” tab + +### Task 2: 建立共享 Focus Panel 单一实现 + +**Files:** +- Modify: `lib/widgets/assistant_focus_panel.dart` +- Modify: `lib/widgets/assistant_focus_panel_core.dart` +- Modify: `lib/widgets/assistant_focus_panel_previews.dart` +- Modify: `lib/widgets/assistant_focus_panel_support.dart` +- Modify: `lib/web/web_assistant_page_core.dart` +- Modify: `lib/web/web_assistant_page_chrome.dart` +- Modify: `lib/web/web_assistant_page_workspace.dart` +- Modify: `lib/web/web_assistant_page_helpers.dart` + +**Step 1: 让 Web 侧直接依赖共享入口** + +把 Web Assistant 页面中对 `web_focus_panel.dart` 的依赖改成共享 `../widgets/assistant_focus_panel.dart`。 + +**Step 2: 去掉 Web 专属 Focus Panel 类型耦合** + +把 `WebAssistantFocusPanel` 的使用点替换为 `AssistantFocusPanel`。 + +**Step 3: 确认共享组件继续使用条件导出的 controller** + +共享 Focus Panel 通过 `../app/app_controller.dart` 获取平台适配后的 `AppController`,不再区分 Desktop/Web 文件树。 + +### Task 3: 退役 Web Focus Panel 副本 + +**Files:** +- Delete: `lib/web/web_focus_panel_core.dart` +- Delete: `lib/web/web_focus_panel_previews.dart` +- Delete: `lib/web/web_focus_panel_support.dart` +- Modify: `lib/web/web_focus_panel.dart` + +**Step 1: 保留一个薄入口或直接删入口** + +推荐做法: + +- `lib/web/web_focus_panel.dart` 变成一行导出共享 widget 的兼容入口 + +```dart +export '../widgets/assistant_focus_panel.dart'; +``` + +**Step 2: 删除三份重复副本** + +删除: + +- `web_focus_panel_core.dart` +- `web_focus_panel_previews.dart` +- `web_focus_panel_support.dart` + +### Task 4: 更新测试与守护规则 + +**Files:** +- Modify: `test/widgets/assistant_focus_panel_suite.dart` +- Verify: `test/web/web_ui_browser_test.dart` + +**Step 1: 更新文件存在性 / 行数守护** + +将原来对 Web 重复文件的守护改成对共享实现的守护,避免测试继续依赖已删除文件路径。 + +**Step 2: 跑 widget 测试** + +Run: + +```bash +flutter test test/widgets/assistant_focus_panel_suite.dart +``` + +Expected: + +- PASS + +**Step 3: 跑 browser 测试并记录结果** + +Run: + +```bash +flutter test --platform chrome test/web/web_ui_browser_test.dart +``` + +Expected: + +- 如果仍失败,需要确认是否仍是既有 “集成” tab 失败 +- 如果失败点变化,说明本轮重构影响了 Web 链路,需要回退排查 + +### Task 5: 回归与提交 + +**Files:** +- Verify only + +**Step 1: 检查 git diff** + +确认本轮只涉及: + +- 文档 +- Focus Panel 共享化 +- 测试更新 + +**Step 2: 记录 residual risks** + +记录: + +- `SettingsPage` / `WebSettingsPage` 尚未统一 +- `settings detail flow` 仍在 Desktop overview 体系里 +- `ios_mobile_shell.dart` 仍未清理 + +**Step 3: 提交** + +建议提交信息: + +```bash +git commit -m "refactor: dedupe assistant focus panel across desktop and web" +``` diff --git a/lib/app/app_shell_desktop.dart b/lib/app/app_shell_desktop.dart index dc3ca2c7..8284562d 100644 --- a/lib/app/app_shell_desktop.dart +++ b/lib/app/app_shell_desktop.dart @@ -10,6 +10,7 @@ import '../widgets/detail_drawer.dart'; import '../widgets/pane_resize_handle.dart'; import '../widgets/sidebar_navigation.dart'; import 'app_controller.dart'; +import 'ui_feature_manifest.dart'; import 'workspace_page_registry.dart'; class AppShell extends StatefulWidget { @@ -70,6 +71,9 @@ class _AppShellState extends State { final isMobile = constraints.maxWidth < 900; final sidebarState = controller.sidebarState; final showSidebar = sidebarState != AppSidebarState.hidden; + final uiFeatures = controller.featuresFor( + resolveUiFeaturePlatformFromContext(context), + ); final embedSidebarIntoAssistant = controller.destination == WorkspaceDestination.assistant && showSidebar; @@ -274,6 +278,11 @@ class _AppShellState extends State { controller.toggleAssistantNavigationDestination, availableDestinations: controller.capabilities.allowedDestinations, + currentSettingsTab: controller.settingsTab, + availableSettingsTabs: + uiFeatures.availableSettingsTabs, + onSettingsTabChanged: (tab) => + controller.openSettings(tab: tab), ), if (sidebarState == AppSidebarState.expanded && !embedSidebarIntoAssistant) diff --git a/lib/app/app_shell_web.dart b/lib/app/app_shell_web.dart index 9691e37e..58e46d6d 100644 --- a/lib/app/app_shell_web.dart +++ b/lib/app/app_shell_web.dart @@ -10,6 +10,7 @@ import '../web/web_workspace_pages.dart'; import '../widgets/pane_resize_handle.dart'; import '../widgets/sidebar_navigation.dart'; import 'app_controller_web.dart'; +import 'ui_feature_manifest.dart'; class AppShell extends StatefulWidget { const AppShell({super.key, required this.controller}); @@ -60,6 +61,7 @@ class _AppShellState extends State { animation: widget.controller, builder: (context, _) { final controller = widget.controller; + final uiFeatures = controller.featuresFor(UiFeaturePlatform.web); final availableDestinations = [ WorkspaceDestination.assistant, @@ -200,6 +202,11 @@ class _AppShellState extends State { controller.toggleAssistantNavigationDestination, availableDestinations: controller.capabilities.allowedDestinations, + currentSettingsTab: controller.settingsTab, + availableSettingsTabs: + uiFeatures.availableSettingsTabs, + onSettingsTabChanged: (tab) => + controller.openSettings(tab: tab), ), if (showWorkspaceSidebar && _sidebarState == AppSidebarState.expanded) diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index b799717a..d4b735ac 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -14,7 +14,6 @@ import '../../runtime/runtime_controllers.dart'; import '../../runtime/runtime_models.dart'; import 'codex_integration_card.dart'; import 'skill_directory_authorization_card.dart'; -import '../../widgets/section_tabs.dart'; import '../../widgets/surface_card.dart'; import '../../widgets/top_bar.dart'; import 'settings_page_sections.dart'; @@ -208,7 +207,6 @@ class SettingsPageStateInternal extends State { builder: (context, _) { final featurePlatform = resolveUiFeaturePlatformFromContext(context); final uiFeatures = controller.featuresFor(featurePlatform); - final availableTabs = uiFeatures.availableSettingsTabs; tabInternal = uiFeatures.sanitizeSettingsTab(controller.settingsTab); detailInternal = controller.settingsDetail; navigationContextInternal = controller.settingsNavigationContext; @@ -217,8 +215,7 @@ class SettingsPageStateInternal extends State { final showingDetail = detailInternal != null; final showGlobalApplyBar = !showingDetail && - (!widget.showSectionTabs || - tabInternal != SettingsTab.gateway || + (tabInternal != SettingsTab.gateway || integrationSubTabInternal == GatewayIntegrationSubTabInternal.acp); return SingleChildScrollView( @@ -270,41 +267,12 @@ class SettingsPageStateInternal extends State { buildGlobalApplyBarInternal(context, controller), const SizedBox(height: 16), ], - if (!showingDetail && widget.showSectionTabs) ...[ - SectionTabs( - items: availableTabs.map((item) => item.label).toList(), - value: tabInternal.label, - onChanged: (value) => setState(() { - tabInternal = availableTabs.firstWhere( - (item) => item.label == value, - ); - detailInternal = null; - navigationContextInternal = null; - controller.setSettingsTab(tabInternal); - }), - ), - const SizedBox(height: 24), - ], - ...(showingDetail - ? buildContentForCurrentStateInternal( - context, - controller, - settings, - uiFeatures, - ) - : widget.showSectionTabs - ? buildContentForCurrentStateInternal( - context, - controller, - settings, - uiFeatures, - ) - : buildOverviewContentInternal( - context, - controller, - settings, - uiFeatures, - )), + ...buildContentForCurrentStateInternal( + context, + controller, + settings, + uiFeatures, + ), ], ), ); diff --git a/lib/web/web_assistant_page_chrome.dart b/lib/web/web_assistant_page_chrome.dart index ce6e0baa..82cf4403 100644 --- a/lib/web/web_assistant_page_chrome.dart +++ b/lib/web/web_assistant_page_chrome.dart @@ -510,7 +510,7 @@ class AssistantQuickPaneInternal extends StatelessWidget { @override Widget build(BuildContext context) { - return WebAssistantFocusPanel(controller: controller); + return AssistantFocusPanel(controller: controller); } } diff --git a/lib/web/web_focus_panel.dart b/lib/web/web_focus_panel.dart index 4093796f..0a654afc 100644 --- a/lib/web/web_focus_panel.dart +++ b/lib/web/web_focus_panel.dart @@ -1,3 +1 @@ -export 'web_focus_panel_core.dart'; -export 'web_focus_panel_previews.dart'; -export 'web_focus_panel_support.dart'; +export '../widgets/assistant_focus_panel.dart'; diff --git a/lib/web/web_focus_panel_core.dart b/lib/web/web_focus_panel_core.dart deleted file mode 100644 index eae53a21..00000000 --- a/lib/web/web_focus_panel_core.dart +++ /dev/null @@ -1,347 +0,0 @@ -// ignore_for_file: unused_import, unnecessary_import - -import 'package:flutter/material.dart'; -import '../app/app_controller_web.dart'; -import '../i18n/app_language.dart'; -import '../models/app_models.dart'; -import '../runtime/runtime_models.dart'; -import '../theme/app_palette.dart'; -import '../widgets/chrome_quick_action_buttons.dart'; -import '../widgets/settings_focus_quick_actions.dart'; -import '../widgets/surface_card.dart'; -import 'web_focus_panel_previews.dart'; -import 'web_focus_panel_support.dart'; - -class WebAssistantFocusPanel extends StatefulWidget { - const WebAssistantFocusPanel({super.key, required this.controller}); - - final AppController controller; - - @override - State createState() => - AssistantFocusPanelStateInternal(); -} - -class WebAssistantFocusDestinationCard extends StatelessWidget { - const WebAssistantFocusDestinationCard({ - super.key, - required this.controller, - required this.destination, - required this.onOpenPage, - required this.onRemoveFavorite, - }); - - final AppController controller; - final AssistantFocusEntry destination; - final VoidCallback onOpenPage; - final Future Function() onRemoveFavorite; - - @override - Widget build(BuildContext context) { - return AssistantFocusWorkbenchInternal( - controller: controller, - destination: destination, - onOpenPage: onOpenPage, - onRemoveFavorite: onRemoveFavorite, - ); - } -} - -class AssistantFocusPanelStateInternal extends State { - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final palette = context.palette; - final favorites = widget.controller.assistantNavigationDestinations; - final available = kAssistantNavigationDestinationCandidates - .where(widget.controller.supportsAssistantFocusEntry) - .where((item) => !favorites.contains(item)) - .toList(growable: false); - - return SurfaceCard( - borderRadius: 16, - padding: EdgeInsets.zero, - tone: SurfaceCardTone.chrome, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(14, 14, 10, 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - appText('关注入口', 'Focused navigation'), - key: const Key('assistant-focus-panel-title'), - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 6), - Text( - appText( - '添加后的入口会直接出现在最左侧侧板。这里负责管理关注项和查看摘要,需要完整页面时再单独打开。', - 'Added entries appear directly in the far-left rail. Manage focused destinations and review summaries here, then open the full page only when needed.', - ), - style: theme.textTheme.bodySmall?.copyWith( - color: palette.textSecondary, - height: 1.35, - ), - ), - ], - ), - ), - if (available.isNotEmpty) - PopupMenuButton( - key: const Key('assistant-focus-add-menu'), - tooltip: appText('添加关注入口', 'Add focused destination'), - onSelected: addFavoriteInternal, - itemBuilder: (context) => available - .map( - (destination) => PopupMenuItem( - value: destination, - child: Row( - children: [ - Icon(destination.icon, size: 18), - const SizedBox(width: 10), - Expanded(child: Text(destination.label)), - ], - ), - ), - ) - .toList(growable: false), - child: Container( - width: 38, - height: 38, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - palette.chromeHighlight.withValues(alpha: 0.94), - palette.chromeSurfacePressed, - ], - ), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: palette.chromeStroke), - boxShadow: [palette.chromeShadowLift], - ), - child: Icon( - Icons.add_rounded, - size: 18, - color: palette.textSecondary, - ), - ), - ), - ], - ), - ), - Divider(height: 1, color: palette.strokeSoft), - Expanded( - child: favorites.isEmpty - ? AssistantFocusEmptyStateInternal( - message: appText( - '还没有关注入口。给功能菜单点星标,或从右上角添加一个入口,加入最左侧侧板。', - 'No focused entries yet. Star a destination or add one from the top-right menu to place it in the far-left rail.', - ), - available: available, - onAdd: addFavoriteInternal, - ) - : ListView.separated( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), - itemCount: favorites.length, - separatorBuilder: (_, _) => const SizedBox(height: 10), - itemBuilder: (context, index) { - final destination = favorites[index]; - return WebAssistantFocusDestinationCard( - controller: widget.controller, - destination: destination, - onOpenPage: () => widget.controller.navigateTo( - destination.destination ?? - WorkspaceDestination.settings, - ), - onRemoveFavorite: () => - removeFavoriteInternal(destination), - ); - }, - ), - ), - ], - ), - ); - } - - Future addFavoriteInternal(AssistantFocusEntry destination) async { - await widget.controller.toggleAssistantNavigationDestination(destination); - if (mounted) { - setState(() {}); - } - } - - Future removeFavoriteInternal(AssistantFocusEntry destination) async { - await widget.controller.toggleAssistantNavigationDestination(destination); - if (mounted) { - setState(() {}); - } - } -} - -class AssistantFocusWorkbenchInternal extends StatelessWidget { - const AssistantFocusWorkbenchInternal({ - super.key, - required this.controller, - required this.destination, - required this.onOpenPage, - required this.onRemoveFavorite, - }); - - final AppController controller; - final AssistantFocusEntry destination; - final VoidCallback onOpenPage; - final Future Function() onRemoveFavorite; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final palette = context.palette; - - return Container( - decoration: BoxDecoration( - color: palette.surfacePrimary, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: palette.strokeSoft), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(14, 14, 10, 10), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: palette.surfaceSecondary, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - destination.icon, - size: 18, - color: palette.accent, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - destination.label, - key: ValueKey( - 'assistant-focus-active-title-${destination.name}', - ), - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 3), - Text( - destination.description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: palette.textSecondary, - height: 1.3, - ), - ), - ], - ), - ), - IconButton( - key: ValueKey( - 'assistant-focus-open-page-${destination.name}', - ), - tooltip: appText('打开全页', 'Open full page'), - onPressed: onOpenPage, - icon: const Icon(Icons.open_in_new_rounded, size: 18), - ), - IconButton( - key: ValueKey( - 'assistant-focus-remove-${destination.name}', - ), - tooltip: appText('取消关注', 'Remove from focused panel'), - onPressed: () async { - await onRemoveFavorite(); - }, - icon: Icon(Icons.star_rounded, color: palette.accent), - ), - ], - ), - ), - Divider(height: 1, color: palette.strokeSoft), - Padding( - padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), - child: AssistantFocusPreviewInternal( - controller: controller, - destination: destination, - ), - ), - ], - ), - ); - } -} - -class AssistantFocusPreviewInternal extends StatelessWidget { - const AssistantFocusPreviewInternal({ - super.key, - required this.controller, - required this.destination, - }); - - final AppController controller; - final AssistantFocusEntry destination; - - @override - Widget build(BuildContext context) { - return switch (destination) { - AssistantFocusEntry.tasks => TasksFocusPreviewInternal( - controller: controller, - ), - AssistantFocusEntry.skills => SkillsFocusPreviewInternal( - controller: controller, - ), - AssistantFocusEntry.nodes => NodesFocusPreviewInternal( - controller: controller, - ), - AssistantFocusEntry.agents => AgentsFocusPreviewInternal( - controller: controller, - ), - AssistantFocusEntry.mcpServer => McpFocusPreviewInternal( - controller: controller, - ), - AssistantFocusEntry.clawHub => ClawHubFocusPreviewInternal( - controller: controller, - ), - AssistantFocusEntry.secrets => SecretsFocusPreviewInternal( - controller: controller, - ), - AssistantFocusEntry.aiGateway => AiGatewayFocusPreviewInternal( - controller: controller, - ), - AssistantFocusEntry.settings => SettingsFocusPreviewInternal( - controller: controller, - ), - AssistantFocusEntry.language => LanguageFocusPreviewInternal( - controller: controller, - ), - AssistantFocusEntry.theme => ThemeFocusPreviewInternal( - controller: controller, - ), - }; - } -} diff --git a/lib/web/web_focus_panel_previews.dart b/lib/web/web_focus_panel_previews.dart deleted file mode 100644 index 028c530b..00000000 --- a/lib/web/web_focus_panel_previews.dart +++ /dev/null @@ -1,636 +0,0 @@ -// ignore_for_file: unused_import, unnecessary_import - -import 'package:flutter/material.dart'; -import '../app/app_controller_web.dart'; -import '../i18n/app_language.dart'; -import '../models/app_models.dart'; -import '../runtime/runtime_models.dart'; -import '../theme/app_palette.dart'; -import '../widgets/chrome_quick_action_buttons.dart'; -import '../widgets/settings_focus_quick_actions.dart'; -import '../widgets/surface_card.dart'; -import 'web_focus_panel_core.dart'; -import 'web_focus_panel_support.dart'; - -class TasksFocusPreviewInternal extends StatelessWidget { - const TasksFocusPreviewInternal({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final items = [ - ...controller.tasksController.running.take(2), - ...controller.tasksController.queue.take(2), - ...controller.tasksController.history.take(1), - ].take(4).toList(growable: false); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FocusPillInternal( - label: appText( - '运行中 ${controller.tasksController.running.length}', - 'Running ${controller.tasksController.running.length}', - ), - ), - FocusPillInternal( - label: appText( - '队列 ${controller.tasksController.queue.length}', - 'Queue ${controller.tasksController.queue.length}', - ), - ), - FocusPillInternal( - label: appText( - '计划 ${controller.tasksController.scheduled.length}', - 'Scheduled ${controller.tasksController.scheduled.length}', - ), - ), - ], - ), - const SizedBox(height: 12), - if (items.isEmpty) - PreviewEmptyStateInternal( - message: - controller.connection.status == - RuntimeConnectionStatus.connected - ? appText('当前没有任务摘要。', 'No task summary yet.') - : appText( - '连接 Gateway 后这里会显示任务摘要。', - 'Connect a gateway to load task summaries.', - ), - ) - else - ...items.map( - (item) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: FocusListTileInternal( - title: item.title, - subtitle: item.summary, - trailing: item.status, - ), - ), - ), - ], - ); - } -} - -class SkillsFocusPreviewInternal extends StatelessWidget { - const SkillsFocusPreviewInternal({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final items = controller.isSingleAgentMode - ? controller - .assistantImportedSkillsForSession(controller.currentSessionKey) - .take(4) - .map( - (skill) => GatewaySkillSummary( - name: skill.label, - description: skill.description, - source: skill.sourcePath, - skillKey: skill.key, - primaryEnv: null, - eligible: true, - disabled: false, - missingBins: const [], - missingEnv: const [], - missingConfig: const [], - ), - ) - .toList(growable: false) - : controller.skills.take(4).toList(growable: false); - if (items.isEmpty) { - return PreviewEmptyStateInternal( - message: controller.isSingleAgentMode - ? (controller.currentSingleAgentNeedsAiGatewayConfiguration - ? appText( - '当前没有可用的外部 Agent ACP 端点,请先配置 LLM API fallback。', - 'No external Agent ACP endpoint is available. Configure LLM API fallback first.', - ) - : appText( - '当前线程还没有已加载技能。切换 provider 后会读取该线程自己的 skills 列表。', - 'No skills are loaded for this thread yet. Switching the provider reloads the thread-owned skills list.', - )) - : controller.connection.status == RuntimeConnectionStatus.connected - ? appText( - '当前代理没有已加载技能。', - 'No skills are loaded for the active agent.', - ) - : appText( - '连接 Gateway 后可查看技能摘要。', - 'Connect a gateway to inspect skills here.', - ), - ); - } - return Column( - children: items - .map( - (skill) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: FocusListTileInternal( - title: skill.name, - subtitle: skill.description, - trailing: skill.disabled - ? appText('已禁用', 'Disabled') - : appText('已启用', 'Enabled'), - ), - ), - ) - .toList(growable: false), - ); - } -} - -class NodesFocusPreviewInternal extends StatelessWidget { - const NodesFocusPreviewInternal({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final items = controller.instances.take(4).toList(growable: false); - if (items.isEmpty) { - return PreviewEmptyStateInternal( - message: appText('当前没有节点可显示。', 'No nodes are available right now.'), - ); - } - return Column( - children: items - .map( - (instance) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: FocusListTileInternal( - title: instance.host?.trim().isNotEmpty == true - ? instance.host! - : instance.id, - subtitle: - [instance.platform, instance.deviceFamily, instance.ip] - .whereType() - .where((item) => item.trim().isNotEmpty) - .join(' · '), - trailing: instance.mode ?? appText('未知', 'Unknown'), - ), - ), - ) - .toList(growable: false), - ); - } -} - -class AgentsFocusPreviewInternal extends StatelessWidget { - const AgentsFocusPreviewInternal({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final items = controller.agents.take(5).toList(growable: false); - if (items.isEmpty) { - return PreviewEmptyStateInternal( - message: appText('当前没有代理摘要。', 'No agents are available right now.'), - ); - } - return Column( - children: items - .map( - (agent) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: FocusListTileInternal( - title: '${agent.emoji} ${agent.name}', - subtitle: agent.id, - trailing: agent.name == controller.activeAgentName - ? appText('当前', 'Active') - : agent.theme, - ), - ), - ) - .toList(growable: false), - ); - } -} - -class McpFocusPreviewInternal extends StatelessWidget { - const McpFocusPreviewInternal({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final items = controller.connectors.take(4).toList(growable: false); - if (items.isEmpty) { - return PreviewEmptyStateInternal( - message: appText( - '当前没有 MCP 连接器。连接 Gateway 后这里会显示工具摘要。', - 'No MCP connectors yet. Connect a gateway to load tool summaries here.', - ), - ); - } - return Column( - children: items - .map( - (connector) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: FocusListTileInternal( - title: connector.label, - subtitle: connector.detailLabel, - trailing: connector.status, - ), - ), - ) - .toList(growable: false), - ); - } -} - -class ClawHubFocusPreviewInternal extends StatelessWidget { - const ClawHubFocusPreviewInternal({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final skillCount = controller.isSingleAgentMode - ? controller.currentAssistantSkillCount - : controller.skills.length; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FocusPillInternal( - label: appText('已加载技能 $skillCount', 'Loaded skills $skillCount'), - ), - FocusPillInternal( - label: appText( - '关注入口 ${controller.assistantNavigationDestinations.length}', - 'Pinned ${controller.assistantNavigationDestinations.length}', - ), - ), - ], - ), - const SizedBox(height: 12), - PreviewEmptyStateInternal( - message: appText( - 'ClawHub 适合放在侧板做快速搜索或安装入口;需要完整终端交互时,再打开全页。', - 'Use ClawHub in the side panel for quick access. Open the full page when you need the terminal workflow.', - ), - ), - ], - ); - } -} - -class SecretsFocusPreviewInternal extends StatelessWidget { - const SecretsFocusPreviewInternal({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final items = controller.secretReferences.take(4).toList(growable: false); - if (items.isEmpty) { - return PreviewEmptyStateInternal( - message: appText( - '当前没有密钥引用摘要。', - 'No masked secret references are available yet.', - ), - ); - } - return Column( - children: items - .map( - (secret) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: FocusListTileInternal( - title: secret.name, - subtitle: '${secret.provider} · ${secret.module}', - trailing: secret.status, - ), - ), - ) - .toList(growable: false), - ); - } -} - -class AiGatewayFocusPreviewInternal extends StatelessWidget { - const AiGatewayFocusPreviewInternal({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final items = controller.models.take(4).toList(growable: false); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FocusPillInternal(label: controller.connection.status.label), - FocusPillInternal( - label: appText( - '模型 ${controller.models.length}', - 'Models ${controller.models.length}', - ), - ), - ], - ), - const SizedBox(height: 12), - if (items.isEmpty) - PreviewEmptyStateInternal( - message: appText( - '当前没有 LLM API 模型摘要。', - 'No LLM API model summary is available yet.', - ), - ) - else - ...items.map( - (model) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: FocusListTileInternal( - title: model.name, - subtitle: model.provider, - trailing: model.id, - ), - ), - ), - ], - ); - } -} - -class SettingsFocusPreviewInternal extends StatelessWidget { - const SettingsFocusPreviewInternal({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final languageLabel = controller.appLanguage == AppLanguage.zh - ? appText('中文', 'Chinese') - : 'English'; - final themeLabel = switch (controller.themeMode) { - ThemeMode.dark => appText('深色', 'Dark'), - ThemeMode.light => appText('浅色', 'Light'), - ThemeMode.system => appText('跟随系统', 'System'), - }; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SettingsFocusQuickActions( - appLanguage: controller.appLanguage, - themeMode: controller.themeMode, - onToggleLanguage: controller.toggleAppLanguage, - onToggleTheme: () { - controller.setThemeMode( - controller.themeMode == ThemeMode.dark - ? ThemeMode.light - : ThemeMode.dark, - ); - }, - languageButtonKey: const Key( - 'assistant-focus-settings-language-toggle', - ), - themeButtonKey: const Key('assistant-focus-settings-theme-toggle'), - ), - const SizedBox(height: 12), - FocusListTileInternal( - title: appText('语言', 'Language'), - subtitle: appText('当前界面语言', 'Current interface language'), - trailing: languageLabel, - ), - const SizedBox(height: 8), - FocusListTileInternal( - title: appText('主题', 'Theme'), - subtitle: appText('当前显示模式', 'Current display mode'), - trailing: themeLabel, - ), - const SizedBox(height: 8), - FocusListTileInternal( - title: appText('执行目标', 'Execution target'), - subtitle: appText( - 'Assistant 默认运行位置', - 'Default assistant execution target', - ), - trailing: controller.assistantExecutionTarget.label, - ), - const SizedBox(height: 8), - FocusListTileInternal( - title: appText('权限', 'Permissions'), - subtitle: appText( - 'Assistant 默认权限级别', - 'Default assistant permission level', - ), - trailing: controller.assistantPermissionLevel.label, - ), - ], - ); - } -} - -class LanguageFocusPreviewInternal extends StatelessWidget { - const LanguageFocusPreviewInternal({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final currentLabel = controller.appLanguage == AppLanguage.zh - ? appText('中文', 'Chinese') - : 'English'; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ChromeLanguageActionButton( - key: const Key('assistant-focus-language-toggle'), - appLanguage: controller.appLanguage, - compact: false, - tooltip: appText('切换语言', 'Toggle language'), - onPressed: controller.toggleAppLanguage, - ), - const SizedBox(height: 12), - FocusListTileInternal( - title: appText('当前语言', 'Current language'), - subtitle: appText( - '点击上方按钮即可在中英文界面之间切换。', - 'Use the button above to switch between Chinese and English.', - ), - trailing: currentLabel, - ), - ], - ); - } -} - -class ThemeFocusPreviewInternal extends StatelessWidget { - const ThemeFocusPreviewInternal({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final themeLabel = switch (controller.themeMode) { - ThemeMode.dark => appText('深色', 'Dark'), - ThemeMode.light => appText('浅色', 'Light'), - ThemeMode.system => appText('跟随系统', 'System'), - }; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ChromeIconActionButton( - key: const Key('assistant-focus-theme-toggle'), - icon: chromeThemeToggleIcon(controller.themeMode), - tooltip: chromeThemeToggleTooltip(controller.themeMode), - onPressed: () { - controller.setThemeMode( - controller.themeMode == ThemeMode.dark - ? ThemeMode.light - : ThemeMode.dark, - ); - }, - ), - const SizedBox(height: 12), - FocusListTileInternal( - title: appText('当前主题', 'Current theme'), - subtitle: appText( - '点击上方按钮即可切换亮度模式。', - 'Use the button above to switch appearance mode.', - ), - trailing: themeLabel, - ), - ], - ); - } -} - -class FocusListTileInternal extends StatelessWidget { - const FocusListTileInternal({ - super.key, - required this.title, - required this.subtitle, - required this.trailing, - }); - - final String title; - final String subtitle; - final String trailing; - - @override - Widget build(BuildContext context) { - final palette = context.palette; - final theme = Theme.of(context); - - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), - decoration: BoxDecoration( - color: palette.surfaceSecondary, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: palette.strokeSoft), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: palette.textSecondary, - height: 1.3, - ), - ), - const SizedBox(height: 8), - Text( - trailing, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelLarge?.copyWith( - color: palette.textPrimary, - ), - ), - ], - ), - ); - } -} - -class FocusPillInternal extends StatelessWidget { - const FocusPillInternal({super.key, required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - 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: Text( - label, - style: theme.textTheme.labelLarge?.copyWith( - color: palette.textSecondary, - ), - ), - ); - } -} - -class PreviewEmptyStateInternal extends StatelessWidget { - const PreviewEmptyStateInternal({super.key, required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - final palette = context.palette; - final theme = Theme.of(context); - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: palette.surfaceSecondary, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: palette.strokeSoft), - ), - child: Text( - message, - style: theme.textTheme.bodySmall?.copyWith( - color: palette.textSecondary, - height: 1.35, - ), - ), - ); - } -} diff --git a/lib/web/web_focus_panel_support.dart b/lib/web/web_focus_panel_support.dart deleted file mode 100644 index eab3d8ed..00000000 --- a/lib/web/web_focus_panel_support.dart +++ /dev/null @@ -1,75 +0,0 @@ -// ignore_for_file: unused_import, unnecessary_import - -import 'package:flutter/material.dart'; -import '../app/app_controller_web.dart'; -import '../i18n/app_language.dart'; -import '../models/app_models.dart'; -import '../runtime/runtime_models.dart'; -import '../theme/app_palette.dart'; -import '../widgets/chrome_quick_action_buttons.dart'; -import '../widgets/settings_focus_quick_actions.dart'; -import '../widgets/surface_card.dart'; -import 'web_focus_panel_core.dart'; -import 'web_focus_panel_previews.dart'; - -class AssistantFocusEmptyStateInternal extends StatelessWidget { - const AssistantFocusEmptyStateInternal({ - super.key, - required this.message, - required this.available, - required this.onAdd, - }); - - final String message; - final List available; - final Future Function(AssistantFocusEntry destination) onAdd; - - @override - Widget build(BuildContext context) { - final palette = context.palette; - final theme = Theme.of(context); - - return ListView( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: palette.surfaceSecondary, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: palette.strokeSoft), - ), - child: Text( - message, - style: theme.textTheme.bodySmall?.copyWith( - color: palette.textSecondary, - height: 1.35, - ), - ), - ), - if (available.isNotEmpty) ...[ - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: available - .map( - (destination) => ActionChip( - key: ValueKey( - 'assistant-focus-add-${destination.name}', - ), - avatar: Icon(destination.icon, size: 16), - label: Text(destination.label), - onPressed: () async { - await onAdd(destination); - }, - ), - ) - .toList(growable: false), - ), - ], - ], - ); - } -} diff --git a/lib/web/web_settings_page_core.dart b/lib/web/web_settings_page_core.dart index be0aa4c9..b3cd7b3c 100644 --- a/lib/web/web_settings_page_core.dart +++ b/lib/web/web_settings_page_core.dart @@ -10,7 +10,6 @@ import '../models/app_models.dart'; import '../runtime/runtime_models.dart'; import '../theme/app_palette.dart'; import '../widgets/desktop_workspace_scaffold.dart'; -import '../widgets/section_tabs.dart'; import '../widgets/surface_card.dart'; import '../widgets/top_bar.dart'; import 'web_settings_page_sections.dart'; @@ -243,12 +242,10 @@ class WebSettingsPageStateInternal extends State { animation: controller, builder: (context, _) { final uiFeatures = controller.featuresFor(UiFeaturePlatform.web); - final availableTabs = uiFeatures.availableSettingsTabs; final currentTab = uiFeatures.sanitizeSettingsTab( controller.settingsTab, ); final showGlobalApplyBar = - !widget.showSectionTabs || currentTab != SettingsTab.gateway || gatewaySubTabInternal == WebGatewaySettingsSubTabInternal.acp; return DesktopWorkspaceScaffold( @@ -291,33 +288,12 @@ class WebSettingsPageStateInternal extends State { buildGlobalApplyBarInternal(context, controller), const SizedBox(height: 16), ], - if (widget.showSectionTabs) ...[ - SectionTabs( - items: availableTabs.map((item) => item.label).toList(), - value: currentTab.label, - onChanged: (label) { - final tab = availableTabs.firstWhere( - (item) => item.label == label, - ); - controller.setSettingsTab(tab); - }, - ), - const SizedBox(height: 24), - ], - ...(widget.showSectionTabs - ? buildTabContentInternal( - context, - controller, - controller.settingsDraft, - currentTab, - ) - : buildOverviewContentInternal( - context, - controller, - controller.settingsDraft, - availableTabs, - currentTab, - )), + ...buildTabContentInternal( + context, + controller, + controller.settingsDraft, + currentTab, + ), ], ), ), diff --git a/lib/widgets/assistant_focus_panel_core.dart b/lib/widgets/assistant_focus_panel_core.dart index e831172c..53eb0984 100644 --- a/lib/widgets/assistant_focus_panel_core.dart +++ b/lib/widgets/assistant_focus_panel_core.dart @@ -1,7 +1,7 @@ // ignore_for_file: unused_import, unnecessary_import import 'package:flutter/material.dart'; -import '../app/app_controller.dart'; +import '../app/app_controller.dart' as app; import '../i18n/app_language.dart'; import '../models/app_models.dart'; import '../runtime/runtime_models.dart'; @@ -12,10 +12,16 @@ import 'surface_card.dart'; import 'assistant_focus_panel_previews.dart'; import 'assistant_focus_panel_support.dart'; +typedef AssistantFocusControllerInternal = Object; + +app.AppController castAssistantFocusControllerInternal( + AssistantFocusControllerInternal controller, +) => controller as app.AppController; + class AssistantFocusPanel extends StatefulWidget { const AssistantFocusPanel({super.key, required this.controller}); - final AppController controller; + final AssistantFocusControllerInternal controller; @override State createState() => @@ -31,7 +37,7 @@ class AssistantFocusDestinationCard extends StatelessWidget { required this.onRemoveFavorite, }); - final AppController controller; + final AssistantFocusControllerInternal controller; final AssistantFocusEntry destination; final VoidCallback onOpenPage; final Future Function() onRemoveFavorite; @@ -50,11 +56,12 @@ class AssistantFocusDestinationCard extends StatelessWidget { class AssistantFocusPanelStateInternal extends State { @override Widget build(BuildContext context) { + final controller = castAssistantFocusControllerInternal(widget.controller); final theme = Theme.of(context); final palette = context.palette; - final favorites = widget.controller.assistantNavigationDestinations; + final favorites = controller.assistantNavigationDestinations; final available = kAssistantNavigationDestinationCandidates - .where(widget.controller.supportsAssistantFocusEntry) + .where(controller.supportsAssistantFocusEntry) .where((item) => !favorites.contains(item)) .toList(growable: false); @@ -157,9 +164,9 @@ class AssistantFocusPanelStateInternal extends State { itemBuilder: (context, index) { final destination = favorites[index]; return AssistantFocusDestinationCard( - controller: widget.controller, + controller: controller, destination: destination, - onOpenPage: () => widget.controller.navigateTo( + onOpenPage: () => controller.navigateTo( destination.destination ?? WorkspaceDestination.settings, ), @@ -175,14 +182,16 @@ class AssistantFocusPanelStateInternal extends State { } Future addFavoriteInternal(AssistantFocusEntry destination) async { - await widget.controller.toggleAssistantNavigationDestination(destination); + final controller = castAssistantFocusControllerInternal(widget.controller); + await controller.toggleAssistantNavigationDestination(destination); if (mounted) { setState(() {}); } } Future removeFavoriteInternal(AssistantFocusEntry destination) async { - await widget.controller.toggleAssistantNavigationDestination(destination); + final controller = castAssistantFocusControllerInternal(widget.controller); + await controller.toggleAssistantNavigationDestination(destination); if (mounted) { setState(() {}); } @@ -198,7 +207,7 @@ class AssistantFocusWorkbenchInternal extends StatelessWidget { required this.onRemoveFavorite, }); - final AppController controller; + final AssistantFocusControllerInternal controller; final AssistantFocusEntry destination; final VoidCallback onOpenPage; final Future Function() onRemoveFavorite; @@ -303,7 +312,7 @@ class AssistantFocusPreviewInternal extends StatelessWidget { required this.destination, }); - final AppController controller; + final AssistantFocusControllerInternal controller; final AssistantFocusEntry destination; @override diff --git a/lib/widgets/assistant_focus_panel_previews.dart b/lib/widgets/assistant_focus_panel_previews.dart index ea48bd5a..13971003 100644 --- a/lib/widgets/assistant_focus_panel_previews.dart +++ b/lib/widgets/assistant_focus_panel_previews.dart @@ -15,14 +15,15 @@ import 'assistant_focus_panel_support.dart'; class TasksFocusPreviewInternal extends StatelessWidget { const TasksFocusPreviewInternal({super.key, required this.controller}); - final AppController controller; + final AssistantFocusControllerInternal controller; @override Widget build(BuildContext context) { + final typedController = castAssistantFocusControllerInternal(controller); final items = [ - ...controller.tasksController.running.take(2), - ...controller.tasksController.queue.take(2), - ...controller.tasksController.history.take(1), + ...typedController.tasksController.running.take(2), + ...typedController.tasksController.queue.take(2), + ...typedController.tasksController.history.take(1), ].take(4).toList(growable: false); return Column( @@ -34,20 +35,20 @@ class TasksFocusPreviewInternal extends StatelessWidget { children: [ FocusPillInternal( label: appText( - '运行中 ${controller.tasksController.running.length}', - 'Running ${controller.tasksController.running.length}', + '运行中 ${typedController.tasksController.running.length}', + 'Running ${typedController.tasksController.running.length}', ), ), FocusPillInternal( label: appText( - '队列 ${controller.tasksController.queue.length}', - 'Queue ${controller.tasksController.queue.length}', + '队列 ${typedController.tasksController.queue.length}', + 'Queue ${typedController.tasksController.queue.length}', ), ), FocusPillInternal( label: appText( - '计划 ${controller.tasksController.scheduled.length}', - 'Scheduled ${controller.tasksController.scheduled.length}', + '计划 ${typedController.tasksController.scheduled.length}', + 'Scheduled ${typedController.tasksController.scheduled.length}', ), ), ], @@ -56,7 +57,7 @@ class TasksFocusPreviewInternal extends StatelessWidget { if (items.isEmpty) PreviewEmptyStateInternal( message: - controller.connection.status == + typedController.connection.status == RuntimeConnectionStatus.connected ? appText('当前没有任务摘要。', 'No task summary yet.') : appText( @@ -83,13 +84,16 @@ class TasksFocusPreviewInternal extends StatelessWidget { class SkillsFocusPreviewInternal extends StatelessWidget { const SkillsFocusPreviewInternal({super.key, required this.controller}); - final AppController controller; + final AssistantFocusControllerInternal controller; @override Widget build(BuildContext context) { - final items = controller.isSingleAgentMode - ? controller - .assistantImportedSkillsForSession(controller.currentSessionKey) + final typedController = castAssistantFocusControllerInternal(controller); + final items = typedController.isSingleAgentMode + ? typedController + .assistantImportedSkillsForSession( + typedController.currentSessionKey, + ) .take(4) .map( (skill) => GatewaySkillSummary( @@ -106,11 +110,11 @@ class SkillsFocusPreviewInternal extends StatelessWidget { ), ) .toList(growable: false) - : controller.skills.take(4).toList(growable: false); + : typedController.skills.take(4).toList(growable: false); if (items.isEmpty) { return PreviewEmptyStateInternal( - message: controller.isSingleAgentMode - ? (controller.currentSingleAgentNeedsAiGatewayConfiguration + message: typedController.isSingleAgentMode + ? (typedController.currentSingleAgentNeedsAiGatewayConfiguration ? appText( '当前没有可用的外部 Agent ACP 端点,请先配置 LLM API fallback。', 'No external Agent ACP endpoint is available. Configure LLM API fallback first.', @@ -119,7 +123,7 @@ class SkillsFocusPreviewInternal extends StatelessWidget { '当前线程还没有已加载技能。切换 provider 后会读取该线程自己的 skills 列表。', 'No skills are loaded for this thread yet. Switching the provider reloads the thread-owned skills list.', )) - : controller.connection.status == RuntimeConnectionStatus.connected + : typedController.connection.status == RuntimeConnectionStatus.connected ? appText( '当前代理没有已加载技能。', 'No skills are loaded for the active agent.', @@ -152,11 +156,12 @@ class SkillsFocusPreviewInternal extends StatelessWidget { class NodesFocusPreviewInternal extends StatelessWidget { const NodesFocusPreviewInternal({super.key, required this.controller}); - final AppController controller; + final AssistantFocusControllerInternal controller; @override Widget build(BuildContext context) { - final items = controller.instances.take(4).toList(growable: false); + final typedController = castAssistantFocusControllerInternal(controller); + final items = typedController.instances.take(4).toList(growable: false); if (items.isEmpty) { return PreviewEmptyStateInternal( message: appText('当前没有节点可显示。', 'No nodes are available right now.'), @@ -188,11 +193,12 @@ class NodesFocusPreviewInternal extends StatelessWidget { class AgentsFocusPreviewInternal extends StatelessWidget { const AgentsFocusPreviewInternal({super.key, required this.controller}); - final AppController controller; + final AssistantFocusControllerInternal controller; @override Widget build(BuildContext context) { - final items = controller.agents.take(5).toList(growable: false); + final typedController = castAssistantFocusControllerInternal(controller); + final items = typedController.agents.take(5).toList(growable: false); if (items.isEmpty) { return PreviewEmptyStateInternal( message: appText('当前没有代理摘要。', 'No agents are available right now.'), @@ -206,7 +212,7 @@ class AgentsFocusPreviewInternal extends StatelessWidget { child: FocusListTileInternal( title: '${agent.emoji} ${agent.name}', subtitle: agent.id, - trailing: agent.name == controller.activeAgentName + trailing: agent.name == typedController.activeAgentName ? appText('当前', 'Active') : agent.theme, ), @@ -220,11 +226,12 @@ class AgentsFocusPreviewInternal extends StatelessWidget { class McpFocusPreviewInternal extends StatelessWidget { const McpFocusPreviewInternal({super.key, required this.controller}); - final AppController controller; + final AssistantFocusControllerInternal controller; @override Widget build(BuildContext context) { - final items = controller.connectors.take(4).toList(growable: false); + final typedController = castAssistantFocusControllerInternal(controller); + final items = typedController.connectors.take(4).toList(growable: false); if (items.isEmpty) { return PreviewEmptyStateInternal( message: appText( @@ -253,13 +260,14 @@ class McpFocusPreviewInternal extends StatelessWidget { class ClawHubFocusPreviewInternal extends StatelessWidget { const ClawHubFocusPreviewInternal({super.key, required this.controller}); - final AppController controller; + final AssistantFocusControllerInternal controller; @override Widget build(BuildContext context) { - final skillCount = controller.isSingleAgentMode - ? controller.currentAssistantSkillCount - : controller.skills.length; + final typedController = castAssistantFocusControllerInternal(controller); + final skillCount = typedController.isSingleAgentMode + ? typedController.currentAssistantSkillCount + : typedController.skills.length; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -272,8 +280,8 @@ class ClawHubFocusPreviewInternal extends StatelessWidget { ), FocusPillInternal( label: appText( - '关注入口 ${controller.assistantNavigationDestinations.length}', - 'Pinned ${controller.assistantNavigationDestinations.length}', + '关注入口 ${typedController.assistantNavigationDestinations.length}', + 'Pinned ${typedController.assistantNavigationDestinations.length}', ), ), ], @@ -293,11 +301,12 @@ class ClawHubFocusPreviewInternal extends StatelessWidget { class SecretsFocusPreviewInternal extends StatelessWidget { const SecretsFocusPreviewInternal({super.key, required this.controller}); - final AppController controller; + final AssistantFocusControllerInternal controller; @override Widget build(BuildContext context) { - final items = controller.secretReferences.take(4).toList(growable: false); + final typedController = castAssistantFocusControllerInternal(controller); + final items = typedController.secretReferences.take(4).toList(growable: false); if (items.isEmpty) { return PreviewEmptyStateInternal( message: appText( @@ -326,11 +335,12 @@ class SecretsFocusPreviewInternal extends StatelessWidget { class AiGatewayFocusPreviewInternal extends StatelessWidget { const AiGatewayFocusPreviewInternal({super.key, required this.controller}); - final AppController controller; + final AssistantFocusControllerInternal controller; @override Widget build(BuildContext context) { - final items = controller.models.take(4).toList(growable: false); + final typedController = castAssistantFocusControllerInternal(controller); + final items = typedController.models.take(4).toList(growable: false); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -338,11 +348,11 @@ class AiGatewayFocusPreviewInternal extends StatelessWidget { spacing: 8, runSpacing: 8, children: [ - FocusPillInternal(label: controller.connection.status.label), + FocusPillInternal(label: typedController.connection.status.label), FocusPillInternal( label: appText( - '模型 ${controller.models.length}', - 'Models ${controller.models.length}', + '模型 ${typedController.models.length}', + 'Models ${typedController.models.length}', ), ), ], @@ -374,14 +384,16 @@ class AiGatewayFocusPreviewInternal extends StatelessWidget { class SettingsFocusPreviewInternal extends StatelessWidget { const SettingsFocusPreviewInternal({super.key, required this.controller}); - final AppController controller; + final AssistantFocusControllerInternal controller; @override Widget build(BuildContext context) { - final languageLabel = controller.appLanguage == AppLanguage.zh + final typedController = castAssistantFocusControllerInternal(controller); + final themeMode = typedController.themeMode; + final languageLabel = typedController.appLanguage == AppLanguage.zh ? appText('中文', 'Chinese') : 'English'; - final themeLabel = switch (controller.themeMode) { + final themeLabel = switch (themeMode) { ThemeMode.dark => appText('深色', 'Dark'), ThemeMode.light => appText('浅色', 'Light'), ThemeMode.system => appText('跟随系统', 'System'), @@ -391,14 +403,12 @@ class SettingsFocusPreviewInternal extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SettingsFocusQuickActions( - appLanguage: controller.appLanguage, - themeMode: controller.themeMode, - onToggleLanguage: controller.toggleAppLanguage, + appLanguage: typedController.appLanguage, + themeMode: themeMode, + onToggleLanguage: typedController.toggleAppLanguage, onToggleTheme: () { - controller.setThemeMode( - controller.themeMode == ThemeMode.dark - ? ThemeMode.light - : ThemeMode.dark, + typedController.setThemeMode( + themeMode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark, ); }, languageButtonKey: const Key( @@ -425,7 +435,7 @@ class SettingsFocusPreviewInternal extends StatelessWidget { 'Assistant 默认运行位置', 'Default assistant execution target', ), - trailing: controller.assistantExecutionTarget.label, + trailing: typedController.assistantExecutionTarget.label, ), const SizedBox(height: 8), FocusListTileInternal( @@ -434,7 +444,7 @@ class SettingsFocusPreviewInternal extends StatelessWidget { 'Assistant 默认权限级别', 'Default assistant permission level', ), - trailing: controller.assistantPermissionLevel.label, + trailing: typedController.assistantPermissionLevel.label, ), ], ); @@ -444,11 +454,12 @@ class SettingsFocusPreviewInternal extends StatelessWidget { class LanguageFocusPreviewInternal extends StatelessWidget { const LanguageFocusPreviewInternal({super.key, required this.controller}); - final AppController controller; + final AssistantFocusControllerInternal controller; @override Widget build(BuildContext context) { - final currentLabel = controller.appLanguage == AppLanguage.zh + final typedController = castAssistantFocusControllerInternal(controller); + final currentLabel = typedController.appLanguage == AppLanguage.zh ? appText('中文', 'Chinese') : 'English'; @@ -457,10 +468,10 @@ class LanguageFocusPreviewInternal extends StatelessWidget { children: [ ChromeLanguageActionButton( key: const Key('assistant-focus-language-toggle'), - appLanguage: controller.appLanguage, + appLanguage: typedController.appLanguage, compact: false, tooltip: appText('切换语言', 'Toggle language'), - onPressed: controller.toggleAppLanguage, + onPressed: typedController.toggleAppLanguage, ), const SizedBox(height: 12), FocusListTileInternal( @@ -479,11 +490,13 @@ class LanguageFocusPreviewInternal extends StatelessWidget { class ThemeFocusPreviewInternal extends StatelessWidget { const ThemeFocusPreviewInternal({super.key, required this.controller}); - final AppController controller; + final AssistantFocusControllerInternal controller; @override Widget build(BuildContext context) { - final themeLabel = switch (controller.themeMode) { + final typedController = castAssistantFocusControllerInternal(controller); + final themeMode = typedController.themeMode; + final themeLabel = switch (themeMode) { ThemeMode.dark => appText('深色', 'Dark'), ThemeMode.light => appText('浅色', 'Light'), ThemeMode.system => appText('跟随系统', 'System'), @@ -494,13 +507,11 @@ class ThemeFocusPreviewInternal extends StatelessWidget { children: [ ChromeIconActionButton( key: const Key('assistant-focus-theme-toggle'), - icon: chromeThemeToggleIcon(controller.themeMode), - tooltip: chromeThemeToggleTooltip(controller.themeMode), + icon: chromeThemeToggleIcon(themeMode), + tooltip: chromeThemeToggleTooltip(themeMode), onPressed: () { - controller.setThemeMode( - controller.themeMode == ThemeMode.dark - ? ThemeMode.light - : ThemeMode.dark, + typedController.setThemeMode( + themeMode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark, ); }, ), diff --git a/lib/widgets/assistant_focus_panel_support.dart b/lib/widgets/assistant_focus_panel_support.dart index dfafb493..773c1ab4 100644 --- a/lib/widgets/assistant_focus_panel_support.dart +++ b/lib/widgets/assistant_focus_panel_support.dart @@ -1,7 +1,6 @@ // ignore_for_file: unused_import, unnecessary_import import 'package:flutter/material.dart'; -import '../app/app_controller.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; import '../runtime/runtime_models.dart'; diff --git a/lib/widgets/sidebar_navigation.dart b/lib/widgets/sidebar_navigation.dart index 603df0a5..ed748b90 100644 --- a/lib/widgets/sidebar_navigation.dart +++ b/lib/widgets/sidebar_navigation.dart @@ -31,6 +31,9 @@ class SidebarNavigation extends StatelessWidget { this.availableDestinations, this.favoriteDestinations = const {}, this.onToggleFavorite, + this.currentSettingsTab, + this.availableSettingsTabs = const [], + this.onSettingsTabChanged, }); final WorkspaceDestination currentSection; @@ -55,6 +58,9 @@ class SidebarNavigation extends StatelessWidget { final Set? availableDestinations; final Set favoriteDestinations; final Future Function(AssistantFocusEntry section)? onToggleFavorite; + final SettingsTab? currentSettingsTab; + final List availableSettingsTabs; + final ValueChanged? onSettingsTabChanged; static const _primarySections = [ WorkspaceDestination.assistant, @@ -193,6 +199,9 @@ class SidebarNavigation extends StatelessWidget { onToggleAccountWorkspaceFollowed, accountSelected: currentSection == WorkspaceDestination.account, + currentSettingsTab: currentSettingsTab, + availableSettingsTabs: availableSettingsTabs, + onSettingsTabChanged: onSettingsTabChanged, showCollapseControl: showCollapseControl, onOpenOnlineWorkspace: onOpenOnlineWorkspace, ), @@ -588,6 +597,9 @@ class SidebarFooter extends StatelessWidget { required this.accountSubtitle, required this.accountWorkspaceFollowed, required this.accountSelected, + required this.currentSettingsTab, + required this.availableSettingsTabs, + this.onSettingsTabChanged, required this.showCollapseControl, this.onToggleAccountWorkspaceFollowed, this.onOpenOnlineWorkspace, @@ -611,10 +623,20 @@ class SidebarFooter extends StatelessWidget { final String accountSubtitle; final bool accountWorkspaceFollowed; final bool accountSelected; + final SettingsTab? currentSettingsTab; + final List availableSettingsTabs; + final ValueChanged? onSettingsTabChanged; final bool showCollapseControl; final Future Function()? onToggleAccountWorkspaceFollowed; final VoidCallback? onOpenOnlineWorkspace; + bool get showSettingsRail => + showSettingsButton && + currentSection == WorkspaceDestination.settings && + currentSettingsTab != null && + availableSettingsTabs.isNotEmpty && + onSettingsTabChanged != null; + @override Widget build(BuildContext context) { final palette = context.palette; @@ -684,6 +706,18 @@ class SidebarFooter extends StatelessWidget { ), const SizedBox(height: 6), ], + if (showSettingsRail) ...[ + ...availableSettingsTabs.map( + (tab) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: _SidebarSettingsCompactButton( + tab: tab, + selected: tab == currentSettingsTab, + onPressed: () => onSettingsTabChanged!(tab), + ), + ), + ), + ], if (onOpenOnlineWorkspace != null) ...[ ChromeIconActionButton( icon: Icons.open_in_new_rounded, @@ -740,6 +774,32 @@ class SidebarFooter extends StatelessWidget { ), const SizedBox(height: AppSpacing.xs), ], + if (showSettingsRail) ...[ + Container( + margin: const EdgeInsets.only(bottom: AppSpacing.xs), + padding: const EdgeInsets.all(AppSpacing.xxs), + decoration: BoxDecoration( + color: palette.chromeSurface, + borderRadius: BorderRadius.circular(AppRadius.button), + border: Border.all(color: palette.strokeSoft), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: availableSettingsTabs + .map( + (tab) => Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xxs), + child: _SidebarSettingsNavItem( + tab: tab, + selected: tab == currentSettingsTab, + onTap: () => onSettingsTabChanged!(tab), + ), + ), + ) + .toList(growable: false), + ), + ), + ], Row( children: [ Expanded( @@ -820,6 +880,185 @@ class SidebarFooter extends StatelessWidget { enum _SidebarItemEmphasis { primary, secondary } +IconData _sidebarSettingsTabIcon(SettingsTab tab) { + return switch (tab) { + SettingsTab.general => Icons.tune_rounded, + SettingsTab.workspace => Icons.folder_open_rounded, + SettingsTab.gateway => Icons.settings_ethernet_rounded, + SettingsTab.agents => Icons.smart_toy_rounded, + SettingsTab.appearance => Icons.palette_outlined, + SettingsTab.diagnostics => Icons.monitor_heart_outlined, + SettingsTab.experimental => Icons.science_outlined, + SettingsTab.about => Icons.info_outline_rounded, + }; +} + +class _SidebarSettingsCompactButton extends StatefulWidget { + const _SidebarSettingsCompactButton({ + required this.tab, + required this.selected, + required this.onPressed, + }); + + final SettingsTab tab; + final bool selected; + final VoidCallback onPressed; + + @override + State<_SidebarSettingsCompactButton> createState() => + _SidebarSettingsCompactButtonState(); +} + +class _SidebarSettingsCompactButtonState + extends State<_SidebarSettingsCompactButton> { + bool hovered = false; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final active = widget.selected || hovered; + final background = active + ? palette.chromeSurfacePressed + : palette.chromeSurface; + + return Tooltip( + message: widget.tab.label, + child: MouseRegion( + onEnter: (_) => setState(() => hovered = true), + onExit: (_) => setState(() => hovered = false), + child: AnimatedContainer( + key: ValueKey('sidebar-settings-tab-${widget.tab.name}'), + duration: const Duration(milliseconds: 160), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + palette.chromeHighlight.withValues(alpha: active ? 0.95 : 0.88), + background, + ], + ), + borderRadius: BorderRadius.circular(AppRadius.button), + border: Border.all( + color: active ? palette.chromeStroke : palette.strokeSoft, + ), + boxShadow: [ + active ? palette.chromeShadowLift : palette.chromeShadowAmbient, + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(AppRadius.button), + onTap: widget.onPressed, + child: SizedBox( + height: AppSizes.sidebarItemHeight, + child: Center( + child: Icon( + _sidebarSettingsTabIcon(widget.tab), + size: AppSizes.sidebarIconSize, + color: active + ? palette.textPrimary + : palette.textSecondary, + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _SidebarSettingsNavItem extends StatefulWidget { + const _SidebarSettingsNavItem({ + required this.tab, + required this.selected, + required this.onTap, + }); + + final SettingsTab tab; + final bool selected; + final VoidCallback onTap; + + @override + State<_SidebarSettingsNavItem> createState() => _SidebarSettingsNavItemState(); +} + +class _SidebarSettingsNavItemState extends State<_SidebarSettingsNavItem> { + bool hovered = false; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + final active = widget.selected || hovered; + + return MouseRegion( + onEnter: (_) => setState(() => hovered = true), + onExit: (_) => setState(() => hovered = false), + child: AnimatedContainer( + key: ValueKey('sidebar-settings-tab-${widget.tab.name}'), + duration: const Duration(milliseconds: 160), + decoration: BoxDecoration( + gradient: active + ? LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + palette.chromeHighlight.withValues(alpha: 0.95), + palette.chromeSurface, + ], + ) + : null, + color: active ? null : Colors.transparent, + borderRadius: BorderRadius.circular(AppRadius.button), + border: Border.all( + color: active ? palette.chromeStroke : Colors.transparent, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(AppRadius.button), + onTap: widget.onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.compact, + ), + child: Row( + children: [ + Icon( + _sidebarSettingsTabIcon(widget.tab), + size: 18, + color: active + ? palette.textPrimary + : palette.textSecondary, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + widget.tab.label, + style: theme.textTheme.labelLarge?.copyWith( + color: active + ? palette.textPrimary + : palette.textSecondary, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + class _SidebarAccountTile extends StatefulWidget { const _SidebarAccountTile({ required this.selected, diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index a8f2ad14..797420be 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -232,7 +232,7 @@ void main() { }, ); - testWidgets('SettingsPage can render as a unified page without internal tabs', ( + testWidgets('SettingsPage renders only the active section without internal tabs', ( WidgetTester tester, ) async { final controller = await createTestController(tester); @@ -248,12 +248,12 @@ void main() { expect(find.byType(SectionTabs), findsNothing); expect(find.text('Application'), findsOneWidget); - expect(find.text('工作区'), findsWidgets); - expect(find.text('OpenClaw Gateway'), findsWidgets); - expect(find.text('LLM 接入点'), findsWidgets); + expect(find.text('OpenClaw Gateway'), findsNothing); + expect(find.text('LLM 接入点'), findsNothing); + expect(find.text('工作区路径'), findsNothing); expect( find.byKey(const ValueKey('external-acp-provider-add-button')), - findsOneWidget, + findsNothing, ); }); @@ -343,12 +343,12 @@ void main() { ); expect(find.byKey(const ValueKey('ai-gateway-url-field')), findsOneWidget); expect(find.byKey(const ValueKey('gateway-mode-field')), findsNothing); - expect(find.text('认证诊断'), findsOneWidget); + expect(find.text('认证诊断'), findsNothing); expect( find.byKey(const ValueKey('external-acp-provider-add-button')), findsOneWidget, ); - expect(find.text('保存并生效'), findsWidgets); + expect(find.byKey(const ValueKey('settings-global-apply-button')), findsNothing); }); testWidgets('SettingsPage vault card exposes concrete K/V fields', ( @@ -402,9 +402,8 @@ void main() { ); expect( find.byKey(const ValueKey('settings-global-apply-button')), - findsOneWidget, + findsNothing, ); - expect(find.text('保存并生效'), findsWidgets); }); testWidgets('SettingsPage ACP wizard adds a custom provider card', ( diff --git a/test/web/web_ui_browser_test.dart b/test/web/web_ui_browser_test.dart index e003e9ba..7167b32c 100644 --- a/test/web/web_ui_browser_test.dart +++ b/test/web/web_ui_browser_test.dart @@ -156,22 +156,22 @@ void main() { findsOneWidget, ); - await tester.tap(find.text('集成')); + await tester.tap( + find.byKey(const ValueKey('sidebar-settings-tab-gateway')), + ); await tester.pumpAndSettle(); expect(find.text('OpenClaw Gateway'), findsWidgets); expect(find.text('LLM 接入点'), findsWidgets); - expect(find.text('ACP 外部接入'), findsWidgets); expect(find.textContaining('浏览器本地存储'), findsOneWidget); expect(find.textContaining('Local Gateway'), findsWidgets); expect(find.textContaining('Remote Gateway'), findsWidgets); - await tester.tap(find.text('ACP 外部接入').last); + await tester.ensureVisible( + find.byKey(const ValueKey('web-external-acp-provider-add-button')), + ); await tester.pumpAndSettle(); - expect(find.text('设置提交流程'), findsOneWidget); - expect(find.text('Codex'), findsWidgets); - expect(find.text('OpenCode'), findsWidgets); expect(find.text('Claude'), findsNothing); expect(find.text('Gemini'), findsNothing); expect( diff --git a/test/widgets/assistant_focus_panel_suite.dart b/test/widgets/assistant_focus_panel_suite.dart index 86062a36..75818aa9 100644 --- a/test/widgets/assistant_focus_panel_suite.dart +++ b/test/widgets/assistant_focus_panel_suite.dart @@ -16,7 +16,8 @@ void main() { test('assistant focus panel core files stay within 800 lines', () { const targets = [ 'lib/widgets/assistant_focus_panel_core.dart', - 'lib/web/web_focus_panel_core.dart', + 'lib/widgets/assistant_focus_panel_previews.dart', + 'lib/widgets/assistant_focus_panel_support.dart', ]; for (final path in targets) { diff --git a/test/widgets/sidebar_navigation_suite.dart b/test/widgets/sidebar_navigation_suite.dart index f10214f4..b55b59f2 100644 --- a/test/widgets/sidebar_navigation_suite.dart +++ b/test/widgets/sidebar_navigation_suite.dart @@ -217,6 +217,64 @@ void main() { }, ); + testWidgets('SidebarNavigation exposes settings sub navigation in sidebar', ( + WidgetTester tester, + ) async { + final changedTabs = []; + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SidebarNavigation( + currentSection: WorkspaceDestination.settings, + sidebarState: AppSidebarState.expanded, + appLanguage: AppLanguage.zh, + themeMode: ThemeMode.light, + currentSettingsTab: SettingsTab.general, + availableSettingsTabs: const [ + SettingsTab.general, + SettingsTab.workspace, + SettingsTab.gateway, + ], + onSettingsTabChanged: changedTabs.add, + onSectionChanged: (_) {}, + onToggleLanguage: () {}, + onCycleSidebarState: () {}, + onExpandFromCollapsed: () {}, + onOpenHome: () {}, + onOpenAccount: () {}, + onOpenThemeToggle: () {}, + accountName: 'Tester', + accountSubtitle: 'Workspace', + onToggleAccountWorkspaceFollowed: () async {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('sidebar-settings-tab-general')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('sidebar-settings-tab-workspace')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('sidebar-settings-tab-gateway')), + findsOneWidget, + ); + + await tester.tap( + find.byKey(const ValueKey('sidebar-settings-tab-gateway')), + ); + await tester.pumpAndSettle(); + + expect(changedTabs, [SettingsTab.gateway]); + }); + testWidgets('SidebarNavigation header uses chevron instead of brand logo', ( WidgetTester tester, ) async {