refactor: dedupe focus panel and move settings nav into sidebar
This commit is contained in:
parent
09ff680cac
commit
8539eab3b7
@ -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 端已有基线失败,不能误判为本轮新引入问题
|
||||
|
||||
146
docs/plans/2026-03-29-focus-panel-dedupe-implementation-plan.md
Normal file
146
docs/plans/2026-03-29-focus-panel-dedupe-implementation-plan.md
Normal file
@ -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"
|
||||
```
|
||||
@ -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<AppShell> {
|
||||
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<AppShell> {
|
||||
controller.toggleAssistantNavigationDestination,
|
||||
availableDestinations:
|
||||
controller.capabilities.allowedDestinations,
|
||||
currentSettingsTab: controller.settingsTab,
|
||||
availableSettingsTabs:
|
||||
uiFeatures.availableSettingsTabs,
|
||||
onSettingsTabChanged: (tab) =>
|
||||
controller.openSettings(tab: tab),
|
||||
),
|
||||
if (sidebarState == AppSidebarState.expanded &&
|
||||
!embedSidebarIntoAssistant)
|
||||
|
||||
@ -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<AppShell> {
|
||||
animation: widget.controller,
|
||||
builder: (context, _) {
|
||||
final controller = widget.controller;
|
||||
final uiFeatures = controller.featuresFor(UiFeaturePlatform.web);
|
||||
final availableDestinations =
|
||||
<WorkspaceDestination>[
|
||||
WorkspaceDestination.assistant,
|
||||
@ -200,6 +202,11 @@ class _AppShellState extends State<AppShell> {
|
||||
controller.toggleAssistantNavigationDestination,
|
||||
availableDestinations:
|
||||
controller.capabilities.allowedDestinations,
|
||||
currentSettingsTab: controller.settingsTab,
|
||||
availableSettingsTabs:
|
||||
uiFeatures.availableSettingsTabs,
|
||||
onSettingsTabChanged: (tab) =>
|
||||
controller.openSettings(tab: tab),
|
||||
),
|
||||
if (showWorkspaceSidebar &&
|
||||
_sidebarState == AppSidebarState.expanded)
|
||||
|
||||
@ -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<SettingsPage> {
|
||||
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<SettingsPage> {
|
||||
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<SettingsPage> {
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -510,7 +510,7 @@ class AssistantQuickPaneInternal extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WebAssistantFocusPanel(controller: controller);
|
||||
return AssistantFocusPanel(controller: controller);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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<WebAssistantFocusPanel> 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<void> Function() onRemoveFavorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AssistantFocusWorkbenchInternal(
|
||||
controller: controller,
|
||||
destination: destination,
|
||||
onOpenPage: onOpenPage,
|
||||
onRemoveFavorite: onRemoveFavorite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AssistantFocusPanelStateInternal extends State<WebAssistantFocusPanel> {
|
||||
@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<AssistantFocusEntry>(
|
||||
key: const Key('assistant-focus-add-menu'),
|
||||
tooltip: appText('添加关注入口', 'Add focused destination'),
|
||||
onSelected: addFavoriteInternal,
|
||||
itemBuilder: (context) => available
|
||||
.map(
|
||||
(destination) => PopupMenuItem<AssistantFocusEntry>(
|
||||
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<void> addFavoriteInternal(AssistantFocusEntry destination) async {
|
||||
await widget.controller.toggleAssistantNavigationDestination(destination);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<String>(
|
||||
'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<String>(
|
||||
'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<String>(
|
||||
'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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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 = <DerivedTaskItem>[
|
||||
...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 <String>[],
|
||||
missingEnv: const <String>[],
|
||||
missingConfig: const <String>[],
|
||||
),
|
||||
)
|
||||
.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<String>()
|
||||
.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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<AssistantFocusEntry> available;
|
||||
final Future<void> 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<String>(
|
||||
'assistant-focus-add-${destination.name}',
|
||||
),
|
||||
avatar: Icon(destination.icon, size: 16),
|
||||
label: Text(destination.label),
|
||||
onPressed: () async {
|
||||
await onAdd(destination);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<WebSettingsPage> {
|
||||
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<WebSettingsPage> {
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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<AssistantFocusPanel> 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<void> Function() onRemoveFavorite;
|
||||
@ -50,11 +56,12 @@ class AssistantFocusDestinationCard extends StatelessWidget {
|
||||
class AssistantFocusPanelStateInternal extends State<AssistantFocusPanel> {
|
||||
@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<AssistantFocusPanel> {
|
||||
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<AssistantFocusPanel> {
|
||||
}
|
||||
|
||||
Future<void> addFavoriteInternal(AssistantFocusEntry destination) async {
|
||||
await widget.controller.toggleAssistantNavigationDestination(destination);
|
||||
final controller = castAssistantFocusControllerInternal(widget.controller);
|
||||
await controller.toggleAssistantNavigationDestination(destination);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<void> Function() onRemoveFavorite;
|
||||
@ -303,7 +312,7 @@ class AssistantFocusPreviewInternal extends StatelessWidget {
|
||||
required this.destination,
|
||||
});
|
||||
|
||||
final AppController controller;
|
||||
final AssistantFocusControllerInternal controller;
|
||||
final AssistantFocusEntry destination;
|
||||
|
||||
@override
|
||||
|
||||
@ -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 = <DerivedTaskItem>[
|
||||
...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,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -31,6 +31,9 @@ class SidebarNavigation extends StatelessWidget {
|
||||
this.availableDestinations,
|
||||
this.favoriteDestinations = const <AssistantFocusEntry>{},
|
||||
this.onToggleFavorite,
|
||||
this.currentSettingsTab,
|
||||
this.availableSettingsTabs = const <SettingsTab>[],
|
||||
this.onSettingsTabChanged,
|
||||
});
|
||||
|
||||
final WorkspaceDestination currentSection;
|
||||
@ -55,6 +58,9 @@ class SidebarNavigation extends StatelessWidget {
|
||||
final Set<WorkspaceDestination>? availableDestinations;
|
||||
final Set<AssistantFocusEntry> favoriteDestinations;
|
||||
final Future<void> Function(AssistantFocusEntry section)? onToggleFavorite;
|
||||
final SettingsTab? currentSettingsTab;
|
||||
final List<SettingsTab> availableSettingsTabs;
|
||||
final ValueChanged<SettingsTab>? onSettingsTabChanged;
|
||||
|
||||
static const _primarySections = <WorkspaceDestination>[
|
||||
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<SettingsTab> availableSettingsTabs;
|
||||
final ValueChanged<SettingsTab>? onSettingsTabChanged;
|
||||
final bool showCollapseControl;
|
||||
final Future<void> 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<String>('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<String>('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,
|
||||
|
||||
@ -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', (
|
||||
|
||||
@ -156,22 +156,22 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('集成'));
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('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(
|
||||
|
||||
@ -16,7 +16,8 @@ void main() {
|
||||
test('assistant focus panel core files stay within 800 lines', () {
|
||||
const targets = <String>[
|
||||
'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) {
|
||||
|
||||
@ -217,6 +217,64 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('SidebarNavigation exposes settings sub navigation in sidebar', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final changedTabs = <SettingsTab>[];
|
||||
|
||||
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>[
|
||||
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<String>('sidebar-settings-tab-general')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('sidebar-settings-tab-workspace')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('sidebar-settings-tab-gateway')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('sidebar-settings-tab-gateway')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(changedTabs, <SettingsTab>[SettingsTab.gateway]);
|
||||
});
|
||||
|
||||
testWidgets('SidebarNavigation header uses chevron instead of brand logo', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user