feat: add ui feature flag release docs pipeline
5
Makefile
@ -7,7 +7,7 @@ PNPM ?= pnpm
|
||||
DART ?= dart
|
||||
DEVICE ?= macos
|
||||
|
||||
.PHONY: help deps analyze test check format run build-linux build-macos build-ios-sim package-deb package-rpm package-linux package-mac install-mac clean build-aris-bridge
|
||||
.PHONY: help deps analyze test check format run build-linux build-macos build-ios-sim package-deb package-rpm package-linux package-mac install-mac clean build-aris-bridge render-release-docs
|
||||
|
||||
help: ## Show available targets
|
||||
@grep -E '^[a-zA-Z0-9_.-]+:.*?## ' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-18s %s\n", $$1, $$2}'
|
||||
@ -26,6 +26,9 @@ check: analyze test ## Run the standard validation suite
|
||||
format: ## Format Dart sources
|
||||
$(DART) format lib test
|
||||
|
||||
render-release-docs: ## Render feature matrix, roadmap, release notes, and changelog docs
|
||||
$(DART) run tool/render_release_docs.dart
|
||||
|
||||
run: ## Run the app on a device or desktop target (DEVICE=macos by default)
|
||||
$(FLUTTER) run -d $(DEVICE)
|
||||
|
||||
|
||||
@ -28,6 +28,15 @@ XWorkmate is an AI workspace shell built with Flutter.
|
||||
- Expanded task CRUD beyond the current assistant-thread-first workflow
|
||||
- Expanded memory APIs beyond `memory/sync`
|
||||
|
||||
## Feature Planning
|
||||
|
||||
- Source of truth: [config/feature_flags.yaml](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/config/feature_flags.yaml)
|
||||
- UI feature matrix: [docs/planning/xworkmate-ui-feature-matrix.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/planning/xworkmate-ui-feature-matrix.md)
|
||||
- Release roadmap: [docs/planning/xworkmate-ui-feature-roadmap.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/planning/xworkmate-ui-feature-roadmap.md)
|
||||
- Release notes: [docs/releases/xworkmate-release-notes.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/releases/xworkmate-release-notes.md)
|
||||
- Changelog: [docs/releases/xworkmate-changelog.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/releases/xworkmate-changelog.md)
|
||||
- Render command: `make render-release-docs`
|
||||
|
||||
## Known Issues
|
||||
|
||||
- ARIS local-first collaboration still depends on a reachable local Ollama endpoint for the strongest offline workflow.
|
||||
|
||||
BIN
assets/branding/xmate_desktop_logo.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
434
config/feature_flags.yaml
Normal file
@ -0,0 +1,434 @@
|
||||
release_policy:
|
||||
debug: [stable, beta, experimental]
|
||||
profile: [stable, beta]
|
||||
release: [stable]
|
||||
|
||||
mobile:
|
||||
navigation:
|
||||
assistant:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile assistant destination
|
||||
ui_surface: mobile_shell
|
||||
tasks:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile tasks destination
|
||||
ui_surface: mobile_shell
|
||||
workspace:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace hub destination
|
||||
ui_surface: mobile_shell
|
||||
secrets:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile secrets destination
|
||||
ui_surface: mobile_shell
|
||||
settings:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings destination
|
||||
ui_surface: mobile_shell
|
||||
workspace:
|
||||
skills:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace skills launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
nodes:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace nodes launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
agents:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace agents launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
mcp_server:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace MCP launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
claw_hub:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace ClawHub launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
ai_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace AI Gateway launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
account:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace account launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
assistant:
|
||||
direct_ai:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile direct AI assistant mode
|
||||
ui_surface: assistant_page
|
||||
local_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile local gateway assistant mode
|
||||
ui_surface: assistant_page
|
||||
relay_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile relay gateway assistant mode
|
||||
ui_surface: assistant_page
|
||||
file_attachments:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile file attachment action in assistant composer
|
||||
ui_surface: assistant_page
|
||||
multi_agent:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile multi-agent toggle in assistant composer
|
||||
ui_surface: assistant_page
|
||||
local_runtime:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Mobile does not expose desktop runtime controls
|
||||
ui_surface: assistant_page
|
||||
settings:
|
||||
general:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings general tab
|
||||
ui_surface: settings_page
|
||||
workspace:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings workspace tab
|
||||
ui_surface: settings_page
|
||||
gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings gateway tab
|
||||
ui_surface: settings_page
|
||||
agents:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings multi-agent tab
|
||||
ui_surface: settings_page
|
||||
appearance:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings appearance tab
|
||||
ui_surface: settings_page
|
||||
diagnostics:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings diagnostics tab
|
||||
ui_surface: settings_page
|
||||
experimental:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings experimental tab
|
||||
ui_surface: settings_page
|
||||
about:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings about tab
|
||||
ui_surface: settings_page
|
||||
experimental_canvas:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile experimental canvas host toggle
|
||||
ui_surface: settings_page
|
||||
experimental_bridge:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile experimental bridge toggle
|
||||
ui_surface: settings_page
|
||||
experimental_debug:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile experimental debug runtime toggle
|
||||
ui_surface: settings_page
|
||||
|
||||
desktop:
|
||||
navigation:
|
||||
assistant:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop assistant destination
|
||||
ui_surface: sidebar_navigation
|
||||
tasks:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop tasks destination
|
||||
ui_surface: sidebar_navigation
|
||||
skills:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop skills destination
|
||||
ui_surface: sidebar_navigation
|
||||
nodes:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop nodes destination
|
||||
ui_surface: sidebar_navigation
|
||||
agents:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop agents destination
|
||||
ui_surface: sidebar_navigation
|
||||
mcp_server:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop MCP Hub destination
|
||||
ui_surface: sidebar_navigation
|
||||
claw_hub:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop ClawHub destination
|
||||
ui_surface: sidebar_navigation
|
||||
secrets:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop secrets destination
|
||||
ui_surface: sidebar_navigation
|
||||
ai_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop AI Gateway destination
|
||||
ui_surface: sidebar_navigation
|
||||
settings:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings destination
|
||||
ui_surface: sidebar_navigation
|
||||
account:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop account destination
|
||||
ui_surface: sidebar_navigation
|
||||
assistant:
|
||||
direct_ai:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop direct AI assistant mode
|
||||
ui_surface: assistant_page
|
||||
local_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop local gateway assistant mode
|
||||
ui_surface: assistant_page
|
||||
relay_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop relay gateway assistant mode
|
||||
ui_surface: assistant_page
|
||||
file_attachments:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop file attachment action in assistant composer
|
||||
ui_surface: assistant_page
|
||||
multi_agent:
|
||||
enabled: true
|
||||
release_tier: beta
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop multi-agent toggle in assistant composer
|
||||
ui_surface: assistant_page
|
||||
local_runtime:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop local runtime and gateway orchestration entry
|
||||
ui_surface: assistant_page
|
||||
settings:
|
||||
general:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings general tab
|
||||
ui_surface: settings_page
|
||||
workspace:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings workspace tab
|
||||
ui_surface: settings_page
|
||||
gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings gateway tab
|
||||
ui_surface: settings_page
|
||||
agents:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings multi-agent tab
|
||||
ui_surface: settings_page
|
||||
appearance:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings appearance tab
|
||||
ui_surface: settings_page
|
||||
diagnostics:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings diagnostics tab
|
||||
ui_surface: settings_page
|
||||
experimental:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings experimental tab
|
||||
ui_surface: settings_page
|
||||
about:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings about tab
|
||||
ui_surface: settings_page
|
||||
experimental_canvas:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop experimental canvas host toggle
|
||||
ui_surface: settings_page
|
||||
experimental_bridge:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop experimental bridge toggle
|
||||
ui_surface: settings_page
|
||||
experimental_debug:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop experimental debug runtime toggle
|
||||
ui_surface: settings_page
|
||||
|
||||
web:
|
||||
navigation:
|
||||
assistant:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web assistant destination
|
||||
ui_surface: web_shell
|
||||
settings:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web settings destination
|
||||
ui_surface: web_shell
|
||||
assistant:
|
||||
direct_ai:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web direct AI assistant mode
|
||||
ui_surface: web_assistant_page
|
||||
relay_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web relay gateway assistant mode
|
||||
ui_surface: web_assistant_page
|
||||
file_attachments:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose file attachments in assistant composer
|
||||
ui_surface: web_assistant_page
|
||||
multi_agent:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose multi-agent assistant toggle
|
||||
ui_surface: web_assistant_page
|
||||
local_gateway:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose local gateway assistant mode
|
||||
ui_surface: web_assistant_page
|
||||
local_runtime:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose desktop runtime controls
|
||||
ui_surface: web_assistant_page
|
||||
settings:
|
||||
general:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web settings general tab
|
||||
ui_surface: web_settings_page
|
||||
gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web settings gateway tab
|
||||
ui_surface: web_settings_page
|
||||
appearance:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web settings appearance tab
|
||||
ui_surface: web_settings_page
|
||||
about:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web settings about tab
|
||||
ui_surface: web_settings_page
|
||||
109
docs/planning/xworkmate-ui-feature-matrix.md
Normal file
@ -0,0 +1,109 @@
|
||||
# XWorkmate UI Feature Matrix
|
||||
|
||||
> Generated by `tool/render_release_docs.dart`
|
||||
> Source manifest: [`config/feature_flags.yaml`](../../config/feature_flags.yaml)
|
||||
> Generated at: `2026-03-22T10:48:05.981301`
|
||||
|
||||
## Release Policy
|
||||
|
||||
| Build Mode | 可见 Tier | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `debug` | `stable, beta, experimental` | 内部开发与功能联调 |
|
||||
| `profile` | `stable, beta` | 预发布验收与性能验证 |
|
||||
| `release` | `stable` | 面向用户交付的正式版本 |
|
||||
|
||||
`release_policy` 是全局上限;单个 flag 还必须同时满足 `enabled: true` 和自身 `build_modes` 才会真正出现在 UI 中。
|
||||
|
||||
## Snapshot Summary
|
||||
|
||||
| 平台 | Flag 总数 | 已启用 | Stable | Beta | Experimental | Disabled |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `mobile` | 29 | 28 | 19 | 0 | 9 | 1 |
|
||||
| `desktop` | 28 | 28 | 21 | 1 | 6 | 0 |
|
||||
| `web` | 12 | 8 | 8 | 0 | 0 | 4 |
|
||||
| `total` | 69 | 64 | 48 | 1 | 15 | 5 |
|
||||
|
||||
## Mobile
|
||||
|
||||
| 模块 | Flag | 状态 | Tier | Build Modes | UI Surface | 说明 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `navigation` | `assistant` | enabled | `stable` | `debug, profile, release` | `mobile_shell` | Mobile assistant destination |
|
||||
| `navigation` | `tasks` | enabled | `stable` | `debug, profile, release` | `mobile_shell` | Mobile tasks destination |
|
||||
| `navigation` | `workspace` | enabled | `stable` | `debug, profile, release` | `mobile_shell` | Mobile workspace hub destination |
|
||||
| `navigation` | `secrets` | enabled | `experimental` | `debug, profile, release` | `mobile_shell` | Mobile secrets destination |
|
||||
| `navigation` | `settings` | enabled | `stable` | `debug, profile, release` | `mobile_shell` | Mobile settings destination |
|
||||
| `workspace` | `skills` | enabled | `stable` | `debug, profile, release` | `mobile_workspace_hub` | Mobile workspace skills launcher |
|
||||
| `workspace` | `nodes` | enabled | `stable` | `debug, profile, release` | `mobile_workspace_hub` | Mobile workspace nodes launcher |
|
||||
| `workspace` | `agents` | enabled | `stable` | `debug, profile, release` | `mobile_workspace_hub` | Mobile workspace agents launcher |
|
||||
| `workspace` | `mcp_server` | enabled | `experimental` | `debug, profile, release` | `mobile_workspace_hub` | Mobile workspace MCP launcher |
|
||||
| `workspace` | `claw_hub` | enabled | `experimental` | `debug, profile, release` | `mobile_workspace_hub` | Mobile workspace ClawHub launcher |
|
||||
| `workspace` | `ai_gateway` | enabled | `stable` | `debug, profile, release` | `mobile_workspace_hub` | Mobile workspace AI Gateway launcher |
|
||||
| `workspace` | `account` | enabled | `experimental` | `debug, profile, release` | `mobile_workspace_hub` | Mobile workspace account launcher |
|
||||
| `assistant` | `direct_ai` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Mobile direct AI assistant mode |
|
||||
| `assistant` | `local_gateway` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Mobile local gateway assistant mode |
|
||||
| `assistant` | `relay_gateway` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Mobile relay gateway assistant mode |
|
||||
| `assistant` | `file_attachments` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Mobile file attachment action in assistant composer |
|
||||
| `assistant` | `multi_agent` | enabled | `experimental` | `debug, profile, release` | `assistant_page` | Mobile multi-agent toggle in assistant composer |
|
||||
| `assistant` | `local_runtime` | disabled | `experimental` | `-` | `assistant_page` | Mobile does not expose desktop runtime controls |
|
||||
| `settings` | `general` | enabled | `stable` | `debug, profile, release` | `settings_page` | Mobile settings general tab |
|
||||
| `settings` | `workspace` | enabled | `stable` | `debug, profile, release` | `settings_page` | Mobile settings workspace tab |
|
||||
| `settings` | `gateway` | enabled | `stable` | `debug, profile, release` | `settings_page` | Mobile settings gateway tab |
|
||||
| `settings` | `agents` | enabled | `stable` | `debug, profile, release` | `settings_page` | Mobile settings multi-agent tab |
|
||||
| `settings` | `appearance` | enabled | `stable` | `debug, profile, release` | `settings_page` | Mobile settings appearance tab |
|
||||
| `settings` | `diagnostics` | enabled | `stable` | `debug, profile, release` | `settings_page` | Mobile settings diagnostics tab |
|
||||
| `settings` | `experimental` | enabled | `experimental` | `debug, profile, release` | `settings_page` | Mobile settings experimental tab |
|
||||
| `settings` | `about` | enabled | `stable` | `debug, profile, release` | `settings_page` | Mobile settings about tab |
|
||||
| `settings` | `experimental_canvas` | enabled | `experimental` | `debug, profile, release` | `settings_page` | Mobile experimental canvas host toggle |
|
||||
| `settings` | `experimental_bridge` | enabled | `experimental` | `debug, profile, release` | `settings_page` | Mobile experimental bridge toggle |
|
||||
| `settings` | `experimental_debug` | enabled | `experimental` | `debug, profile, release` | `settings_page` | Mobile experimental debug runtime toggle |
|
||||
|
||||
## Desktop
|
||||
|
||||
| 模块 | Flag | 状态 | Tier | Build Modes | UI Surface | 说明 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `navigation` | `assistant` | enabled | `stable` | `debug, profile, release` | `sidebar_navigation` | Desktop assistant destination |
|
||||
| `navigation` | `tasks` | enabled | `stable` | `debug, profile, release` | `sidebar_navigation` | Desktop tasks destination |
|
||||
| `navigation` | `skills` | enabled | `stable` | `debug, profile, release` | `sidebar_navigation` | Desktop skills destination |
|
||||
| `navigation` | `nodes` | enabled | `stable` | `debug, profile, release` | `sidebar_navigation` | Desktop nodes destination |
|
||||
| `navigation` | `agents` | enabled | `stable` | `debug, profile, release` | `sidebar_navigation` | Desktop agents destination |
|
||||
| `navigation` | `mcp_server` | enabled | `experimental` | `debug, profile, release` | `sidebar_navigation` | Desktop MCP Hub destination |
|
||||
| `navigation` | `claw_hub` | enabled | `experimental` | `debug, profile, release` | `sidebar_navigation` | Desktop ClawHub destination |
|
||||
| `navigation` | `secrets` | enabled | `stable` | `debug, profile, release` | `sidebar_navigation` | Desktop secrets destination |
|
||||
| `navigation` | `ai_gateway` | enabled | `stable` | `debug, profile, release` | `sidebar_navigation` | Desktop AI Gateway destination |
|
||||
| `navigation` | `settings` | enabled | `stable` | `debug, profile, release` | `sidebar_navigation` | Desktop settings destination |
|
||||
| `navigation` | `account` | enabled | `stable` | `debug, profile, release` | `sidebar_navigation` | Desktop account destination |
|
||||
| `assistant` | `direct_ai` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Desktop direct AI assistant mode |
|
||||
| `assistant` | `local_gateway` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Desktop local gateway assistant mode |
|
||||
| `assistant` | `relay_gateway` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Desktop relay gateway assistant mode |
|
||||
| `assistant` | `file_attachments` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Desktop file attachment action in assistant composer |
|
||||
| `assistant` | `multi_agent` | enabled | `beta` | `debug, profile, release` | `assistant_page` | Desktop multi-agent toggle in assistant composer |
|
||||
| `assistant` | `local_runtime` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Desktop local runtime and gateway orchestration entry |
|
||||
| `settings` | `general` | enabled | `stable` | `debug, profile, release` | `settings_page` | Desktop settings general tab |
|
||||
| `settings` | `workspace` | enabled | `stable` | `debug, profile, release` | `settings_page` | Desktop settings workspace tab |
|
||||
| `settings` | `gateway` | enabled | `stable` | `debug, profile, release` | `settings_page` | Desktop settings gateway tab |
|
||||
| `settings` | `agents` | enabled | `stable` | `debug, profile, release` | `settings_page` | Desktop settings multi-agent tab |
|
||||
| `settings` | `appearance` | enabled | `stable` | `debug, profile, release` | `settings_page` | Desktop settings appearance tab |
|
||||
| `settings` | `diagnostics` | enabled | `stable` | `debug, profile, release` | `settings_page` | Desktop settings diagnostics tab |
|
||||
| `settings` | `experimental` | enabled | `experimental` | `debug, profile, release` | `settings_page` | Desktop settings experimental tab |
|
||||
| `settings` | `about` | enabled | `stable` | `debug, profile, release` | `settings_page` | Desktop settings about tab |
|
||||
| `settings` | `experimental_canvas` | enabled | `experimental` | `debug, profile, release` | `settings_page` | Desktop experimental canvas host toggle |
|
||||
| `settings` | `experimental_bridge` | enabled | `experimental` | `debug, profile, release` | `settings_page` | Desktop experimental bridge toggle |
|
||||
| `settings` | `experimental_debug` | enabled | `experimental` | `debug, profile, release` | `settings_page` | Desktop experimental debug runtime toggle |
|
||||
|
||||
## Web
|
||||
|
||||
| 模块 | Flag | 状态 | Tier | Build Modes | UI Surface | 说明 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `navigation` | `assistant` | enabled | `stable` | `debug, profile, release` | `web_shell` | Web assistant destination |
|
||||
| `navigation` | `settings` | enabled | `stable` | `debug, profile, release` | `web_shell` | Web settings destination |
|
||||
| `assistant` | `direct_ai` | enabled | `stable` | `debug, profile, release` | `web_assistant_page` | Web direct AI assistant mode |
|
||||
| `assistant` | `relay_gateway` | enabled | `stable` | `debug, profile, release` | `web_assistant_page` | Web relay gateway assistant mode |
|
||||
| `assistant` | `file_attachments` | disabled | `experimental` | `-` | `web_assistant_page` | Web does not expose file attachments in assistant composer |
|
||||
| `assistant` | `multi_agent` | disabled | `experimental` | `-` | `web_assistant_page` | Web does not expose multi-agent assistant toggle |
|
||||
| `assistant` | `local_gateway` | disabled | `experimental` | `-` | `web_assistant_page` | Web does not expose local gateway assistant mode |
|
||||
| `assistant` | `local_runtime` | disabled | `experimental` | `-` | `web_assistant_page` | Web does not expose desktop runtime controls |
|
||||
| `settings` | `general` | enabled | `stable` | `debug, profile, release` | `web_settings_page` | Web settings general tab |
|
||||
| `settings` | `gateway` | enabled | `stable` | `debug, profile, release` | `web_settings_page` | Web settings gateway tab |
|
||||
| `settings` | `appearance` | enabled | `stable` | `debug, profile, release` | `web_settings_page` | Web settings appearance tab |
|
||||
| `settings` | `about` | enabled | `stable` | `debug, profile, release` | `web_settings_page` | Web settings about tab |
|
||||
|
||||
71
docs/planning/xworkmate-ui-feature-roadmap.md
Normal file
@ -0,0 +1,71 @@
|
||||
# XWorkmate UI Feature Flag Roadmap
|
||||
|
||||
> Generated by `tool/render_release_docs.dart`
|
||||
> Source manifest: [`config/feature_flags.yaml`](../../config/feature_flags.yaml)
|
||||
> Generated at: `2026-03-22T10:48:05.981301`
|
||||
|
||||
## 规划规则
|
||||
|
||||
- `release_policy` 决定 build mode 的总开关上限:`debug` 可见 `stable / beta / experimental`,`profile` 可见 `stable / beta`,`release` 仅可见 `stable`。
|
||||
- 单个 flag 的交付状态由三层共同决定:`enabled`、`release_tier`、`build_modes`。
|
||||
- `enabled: false` 或 `build_modes: []` 的项,会在文档里继续保留,但不会进入当前 build mode 的用户可见范围。
|
||||
|
||||
## Build Visibility Summary
|
||||
|
||||
| 平台 | Debug Visible | Profile Visible | Release Visible | Suppressed |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `mobile` | 28 | 19 | 19 | 1 |
|
||||
| `desktop` | 28 | 22 | 21 | 0 |
|
||||
| `web` | 8 | 8 | 8 | 4 |
|
||||
|
||||
## Release Baseline
|
||||
|
||||
| 平台 | 数量 | Flag 列表 |
|
||||
| --- | --- | --- |
|
||||
| `mobile` | 19 | `navigation.assistant`, `navigation.tasks`, `navigation.workspace`, `navigation.settings`, `workspace.skills`, `workspace.nodes`, `workspace.agents`, `workspace.ai_gateway`, `assistant.direct_ai`, `assistant.local_gateway`, `assistant.relay_gateway`, `assistant.file_attachments`, `settings.general`, `settings.workspace`, `settings.gateway`, `settings.agents`, `settings.appearance`, `settings.diagnostics`, `settings.about` |
|
||||
| `desktop` | 21 | `navigation.assistant`, `navigation.tasks`, `navigation.skills`, `navigation.nodes`, `navigation.agents`, `navigation.secrets`, `navigation.ai_gateway`, `navigation.settings`, `navigation.account`, `assistant.direct_ai`, `assistant.local_gateway`, `assistant.relay_gateway`, `assistant.file_attachments`, `assistant.local_runtime`, `settings.general`, `settings.workspace`, `settings.gateway`, `settings.agents`, `settings.appearance`, `settings.diagnostics`, `settings.about` |
|
||||
| `web` | 8 | `navigation.assistant`, `navigation.settings`, `assistant.direct_ai`, `assistant.relay_gateway`, `settings.general`, `settings.gateway`, `settings.appearance`, `settings.about` |
|
||||
|
||||
## Profile-only Lane
|
||||
|
||||
| 平台 | 数量 | 相比 Release 新增 |
|
||||
| --- | --- | --- |
|
||||
| `mobile` | 0 | - |
|
||||
| `desktop` | 1 | `assistant.multi_agent` |
|
||||
| `web` | 0 | - |
|
||||
|
||||
## Debug-only Experimental Lane
|
||||
|
||||
| 平台 | 数量 | 相比 Profile 新增 |
|
||||
| --- | --- | --- |
|
||||
| `mobile` | 9 | `navigation.secrets`, `workspace.mcp_server`, `workspace.claw_hub`, `workspace.account`, `assistant.multi_agent`, `settings.experimental`, `settings.experimental_canvas`, `settings.experimental_bridge`, `settings.experimental_debug` |
|
||||
| `desktop` | 6 | `navigation.mcp_server`, `navigation.claw_hub`, `settings.experimental`, `settings.experimental_canvas`, `settings.experimental_bridge`, `settings.experimental_debug` |
|
||||
| `web` | 0 | - |
|
||||
|
||||
## Explicitly Suppressed
|
||||
|
||||
| 平台 | 数量 | Flag 列表 |
|
||||
| --- | --- | --- |
|
||||
| `mobile` | 1 | `assistant.local_runtime` |
|
||||
| `desktop` | 0 | - |
|
||||
| `web` | 4 | `assistant.file_attachments`, `assistant.multi_agent`, `assistant.local_gateway`, `assistant.local_runtime` |
|
||||
|
||||
## Tier Inventory
|
||||
|
||||
### Mobile
|
||||
|
||||
- `stable`: `navigation.assistant`, `navigation.tasks`, `navigation.workspace`, `navigation.settings`, `workspace.skills`, `workspace.nodes`, `workspace.agents`, `workspace.ai_gateway`, `assistant.direct_ai`, `assistant.local_gateway`, `assistant.relay_gateway`, `assistant.file_attachments`, `settings.general`, `settings.workspace`, `settings.gateway`, `settings.agents`, `settings.appearance`, `settings.diagnostics`, `settings.about`
|
||||
- `experimental`: `navigation.secrets`, `workspace.mcp_server`, `workspace.claw_hub`, `workspace.account`, `assistant.multi_agent`, `settings.experimental`, `settings.experimental_canvas`, `settings.experimental_bridge`, `settings.experimental_debug`
|
||||
- `disabled`: `assistant.local_runtime`
|
||||
|
||||
### Desktop
|
||||
|
||||
- `stable`: `navigation.assistant`, `navigation.tasks`, `navigation.skills`, `navigation.nodes`, `navigation.agents`, `navigation.secrets`, `navigation.ai_gateway`, `navigation.settings`, `navigation.account`, `assistant.direct_ai`, `assistant.local_gateway`, `assistant.relay_gateway`, `assistant.file_attachments`, `assistant.local_runtime`, `settings.general`, `settings.workspace`, `settings.gateway`, `settings.agents`, `settings.appearance`, `settings.diagnostics`, `settings.about`
|
||||
- `beta`: `assistant.multi_agent`
|
||||
- `experimental`: `navigation.mcp_server`, `navigation.claw_hub`, `settings.experimental`, `settings.experimental_canvas`, `settings.experimental_bridge`, `settings.experimental_debug`
|
||||
|
||||
### Web
|
||||
|
||||
- `stable`: `navigation.assistant`, `navigation.settings`, `assistant.direct_ai`, `assistant.relay_gateway`, `settings.general`, `settings.gateway`, `settings.appearance`, `settings.about`
|
||||
- `disabled`: `assistant.file_attachments`, `assistant.multi_agent`, `assistant.local_gateway`, `assistant.local_runtime`
|
||||
|
||||
43
docs/releases/xworkmate-changelog.md
Normal file
@ -0,0 +1,43 @@
|
||||
# XWorkmate Changelog
|
||||
|
||||
> Generated by `tool/render_release_docs.dart`
|
||||
> Source manifest: [`config/feature_flags.yaml`](../../config/feature_flags.yaml)
|
||||
> Generated at: `2026-03-22T10:48:05.981301`
|
||||
|
||||
## Git Snapshot
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Branch | `main` |
|
||||
| Head Commit | `650071a` |
|
||||
| Head Tags | `-` |
|
||||
| Latest Tag | `v0.5` |
|
||||
| Previous Tag | `v0.4` |
|
||||
| Comparison Range | `v0.5..HEAD` |
|
||||
|
||||
## Recent Tags
|
||||
|
||||
| Tag | Date |
|
||||
| --- | --- |
|
||||
| `v0.5` | `2026-03-20` |
|
||||
| `v0.4` | `2026-03-15` |
|
||||
| `v0.2` | `2026-03-12` |
|
||||
| `v0.1` | `2026-03-11` |
|
||||
|
||||
## Commits
|
||||
|
||||
| Hash | Date | Author | Subject |
|
||||
| --- | --- | --- | --- |
|
||||
| `650071a` | `2026-03-21` | Haitao Pan | Merge branch 'codex/windows-parity' |
|
||||
| `f2fb948` | `2026-03-21` | Haitao Pan | Merge branch 'codex/linux-gnome-desktop-parity' |
|
||||
| `cbcfb90` | `2026-03-21` | Haitao Pan | Add Flutter web assistant shell |
|
||||
| `de8710e` | `2026-03-21` | Haitao Pan | Add mobile-safe controls for mobile shell |
|
||||
| `f65bb15` | `2026-03-21` | Haitao Pan | Adjust desktop sidebar default width |
|
||||
| `dab77eb` | `2026-03-21` | Haitao Pan | Add multi-platform build and release workflow |
|
||||
| `a4225d5` | `2026-03-21` | Haitao Pan | fix(windows): vendor secure storage plugin without ATL |
|
||||
| `3bf71e9` | `2026-03-21` | Haitao Pan | fix(linux): unblock parity desktop builds |
|
||||
| `89ed967` | `2026-03-20` | Haitao Pan | test(ai-gateway): keep secrets in secure storage |
|
||||
| `40159bd` | `2026-03-20` | Haitao Pan | feat: make assistant composer height resizable |
|
||||
| `0d3b9b1` | `2026-03-20` | Haitao Pan | refactor: align multi-agent workflow with real ollama cli |
|
||||
| `7793e92` | `2026-03-20` | Haitao Pan | refactor: unify settings drill-in navigation |
|
||||
| `04f3474` | `2026-03-20` | Haitao Pan | Synchronize assistant threads and markdown view |
|
||||
65
docs/releases/xworkmate-release-notes.md
Normal file
@ -0,0 +1,65 @@
|
||||
# XWorkmate Release Notes
|
||||
|
||||
> Generated by `tool/render_release_docs.dart`
|
||||
> Source manifest: [`config/feature_flags.yaml`](../../config/feature_flags.yaml)
|
||||
> Generated at: `2026-03-22T10:48:05.981301`
|
||||
|
||||
## Git Snapshot
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Branch | `main` |
|
||||
| Head Commit | `650071a` |
|
||||
| Head Tags | `-` |
|
||||
| Latest Tag | `v0.5` |
|
||||
| Previous Tag | `v0.4` |
|
||||
| Comparison Range | `v0.5..HEAD` |
|
||||
| Commit Count | 13 |
|
||||
|
||||
## Feature Snapshot
|
||||
|
||||
| 平台 | Debug | Profile | Release | Suppressed |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `mobile` | 28 | 19 | 19 | 1 |
|
||||
| `desktop` | 28 | 22 | 21 | 0 |
|
||||
| `web` | 8 | 8 | 8 | 4 |
|
||||
|
||||
## Current Focus
|
||||
|
||||
- `release` 当前面向用户暴露 48 个 UI feature flags,全部来自 `stable` tier。
|
||||
- `profile` 相比 `release` 额外开放 1 个预发布条目: `desktop.assistant.multi_agent`。
|
||||
- `debug` 相比 `profile` 额外开放 15 个实验条目: `mobile.navigation.secrets`, `mobile.workspace.mcp_server`, `mobile.workspace.claw_hub`, `mobile.workspace.account`, `mobile.assistant.multi_agent`, `mobile.settings.experimental`, `mobile.settings.experimental_canvas`, `mobile.settings.experimental_bridge`, `mobile.settings.experimental_debug`, `desktop.navigation.mcp_server`, `desktop.navigation.claw_hub`, `desktop.settings.experimental`, `desktop.settings.experimental_canvas`, `desktop.settings.experimental_bridge`, `desktop.settings.experimental_debug`。
|
||||
|
||||
## Commit Highlights
|
||||
|
||||
### Features
|
||||
|
||||
- `cbcfb90` Add Flutter web assistant shell
|
||||
- `de8710e` Add mobile-safe controls for mobile shell
|
||||
- `dab77eb` Add multi-platform build and release workflow
|
||||
- `40159bd` feat: make assistant composer height resizable
|
||||
|
||||
### Fixes
|
||||
|
||||
- `a4225d5` fix(windows): vendor secure storage plugin without ATL
|
||||
- `3bf71e9` fix(linux): unblock parity desktop builds
|
||||
|
||||
### Tests
|
||||
|
||||
- `89ed967` test(ai-gateway): keep secrets in secure storage
|
||||
|
||||
### Refactors
|
||||
|
||||
- `0d3b9b1` refactor: align multi-agent workflow with real ollama cli
|
||||
- `7793e92` refactor: unify settings drill-in navigation
|
||||
|
||||
### Merges
|
||||
|
||||
- `650071a` Merge branch 'codex/windows-parity'
|
||||
- `f2fb948` Merge branch 'codex/linux-gnome-desktop-parity'
|
||||
|
||||
### Other
|
||||
|
||||
- `f65bb15` Adjust desktop sidebar default width
|
||||
- `04f3474` Synchronize assistant threads and markdown view
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 30 KiB |
@ -6,9 +6,12 @@ import '../theme/app_theme.dart';
|
||||
import 'app_controller.dart';
|
||||
import 'app_metadata.dart';
|
||||
import 'app_shell.dart';
|
||||
import 'ui_feature_manifest.dart';
|
||||
|
||||
class XWorkmateApp extends StatefulWidget {
|
||||
const XWorkmateApp({super.key});
|
||||
const XWorkmateApp({super.key, this.featureManifest});
|
||||
|
||||
final UiFeatureManifest? featureManifest;
|
||||
|
||||
@override
|
||||
State<XWorkmateApp> createState() => _XWorkmateAppState();
|
||||
@ -20,7 +23,9 @@ class _XWorkmateAppState extends State<XWorkmateApp> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AppController();
|
||||
_controller = AppController(
|
||||
uiFeatureManifest: widget.featureManifest ?? UiFeatureManifest.fallback(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import '../models/app_models.dart';
|
||||
import 'ui_feature_manifest.dart';
|
||||
|
||||
class AppCapabilities {
|
||||
const AppCapabilities({
|
||||
@ -21,36 +22,14 @@ class AppCapabilities {
|
||||
return allowedDestinations.contains(destination);
|
||||
}
|
||||
|
||||
static const desktop = AppCapabilities(
|
||||
allowedDestinations: <WorkspaceDestination>{
|
||||
WorkspaceDestination.assistant,
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.skills,
|
||||
WorkspaceDestination.nodes,
|
||||
WorkspaceDestination.agents,
|
||||
WorkspaceDestination.mcpServer,
|
||||
WorkspaceDestination.clawHub,
|
||||
WorkspaceDestination.secrets,
|
||||
WorkspaceDestination.aiGateway,
|
||||
WorkspaceDestination.settings,
|
||||
WorkspaceDestination.account,
|
||||
},
|
||||
supportsFileAttachments: true,
|
||||
supportsLocalGateway: true,
|
||||
supportsRelayGateway: true,
|
||||
supportsDesktopRuntime: true,
|
||||
supportsDiagnostics: true,
|
||||
);
|
||||
|
||||
static const web = AppCapabilities(
|
||||
allowedDestinations: <WorkspaceDestination>{
|
||||
WorkspaceDestination.assistant,
|
||||
WorkspaceDestination.settings,
|
||||
},
|
||||
supportsFileAttachments: false,
|
||||
supportsLocalGateway: false,
|
||||
supportsRelayGateway: true,
|
||||
supportsDesktopRuntime: false,
|
||||
supportsDiagnostics: false,
|
||||
);
|
||||
factory AppCapabilities.fromFeatureAccess(UiFeatureAccess access) {
|
||||
return AppCapabilities(
|
||||
allowedDestinations: access.allowedDestinations,
|
||||
supportsFileAttachments: access.supportsFileAttachments,
|
||||
supportsLocalGateway: access.supportsLocalGateway,
|
||||
supportsRelayGateway: access.supportsRelayGateway,
|
||||
supportsDesktopRuntime: access.supportsDesktopRuntime,
|
||||
supportsDiagnostics: access.supportsDiagnostics,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_metadata.dart';
|
||||
import 'app_capabilities.dart';
|
||||
import 'ui_feature_manifest.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
@ -34,8 +35,13 @@ class AppController extends ChangeNotifier {
|
||||
SecureConfigStore? store,
|
||||
RuntimeCoordinator? runtimeCoordinator,
|
||||
DesktopPlatformService? desktopPlatformService,
|
||||
UiFeatureManifest? uiFeatureManifest,
|
||||
}) {
|
||||
_store = store ?? SecureConfigStore();
|
||||
_uiFeatureManifest = uiFeatureManifest ?? UiFeatureManifest.fallback();
|
||||
_hostUiFeaturePlatform = Platform.isIOS || Platform.isAndroid
|
||||
? UiFeaturePlatform.mobile
|
||||
: UiFeaturePlatform.desktop;
|
||||
|
||||
final resolvedRuntimeCoordinator =
|
||||
runtimeCoordinator ??
|
||||
@ -86,6 +92,8 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
late final SecureConfigStore _store;
|
||||
late final UiFeatureManifest _uiFeatureManifest;
|
||||
late final UiFeaturePlatform _hostUiFeaturePlatform;
|
||||
|
||||
late final RuntimeCoordinator _runtimeCoordinator;
|
||||
late final CodeAgentNodeOrchestrator _codeAgentNodeOrchestrator;
|
||||
@ -142,7 +150,9 @@ class AppController extends ChangeNotifier {
|
||||
bool _disposed = false;
|
||||
|
||||
WorkspaceDestination get destination => _destination;
|
||||
AppCapabilities get capabilities => AppCapabilities.desktop;
|
||||
UiFeatureManifest get uiFeatureManifest => _uiFeatureManifest;
|
||||
AppCapabilities get capabilities =>
|
||||
AppCapabilities.fromFeatureAccess(featuresFor(_hostUiFeaturePlatform));
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
AppSidebarState get sidebarState => _sidebarState;
|
||||
ModulesTab get modulesTab => _modulesTab;
|
||||
@ -156,6 +166,10 @@ class AppController extends ChangeNotifier {
|
||||
bool get initializing => _initializing;
|
||||
String? get bootstrapError => _bootstrapError;
|
||||
|
||||
UiFeatureAccess featuresFor(UiFeaturePlatform platform) {
|
||||
return _uiFeatureManifest.forPlatform(platform);
|
||||
}
|
||||
|
||||
RuntimeCoordinator get runtimeCoordinator => _runtimeCoordinator;
|
||||
GatewayRuntime get _runtime => _runtimeCoordinator.gateway;
|
||||
GatewayRuntime get runtime => _runtime;
|
||||
@ -597,7 +611,7 @@ class AppController extends ChangeNotifier {
|
||||
List<WorkspaceDestination> get assistantNavigationDestinations =>
|
||||
normalizeAssistantNavigationDestinations(
|
||||
settings.assistantNavigationDestinations,
|
||||
);
|
||||
).where(capabilities.supportsDestination).toList(growable: false);
|
||||
|
||||
List<GatewayChatMessage> get chatMessages {
|
||||
final sessionKey = _normalizedAssistantSessionKey(
|
||||
@ -648,8 +662,10 @@ class AppController extends ChangeNotifier {
|
||||
String sessionKey,
|
||||
) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
return _assistantThreadRecords[normalizedSessionKey]?.executionTarget ??
|
||||
settings.assistantExecutionTarget;
|
||||
return _sanitizeExecutionTarget(
|
||||
_assistantThreadRecords[normalizedSessionKey]?.executionTarget ??
|
||||
settings.assistantExecutionTarget,
|
||||
);
|
||||
}
|
||||
|
||||
AssistantMessageViewMode assistantMessageViewModeForSession(
|
||||
@ -713,6 +729,9 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void navigateTo(WorkspaceDestination destination) {
|
||||
if (!capabilities.supportsDestination(destination)) {
|
||||
return;
|
||||
}
|
||||
final nextModulesTab = switch (destination) {
|
||||
WorkspaceDestination.nodes => ModulesTab.nodes,
|
||||
WorkspaceDestination.agents => ModulesTab.agents,
|
||||
@ -741,11 +760,17 @@ class AppController extends ChangeNotifier {
|
||||
_runtime.snapshot.mainSessionKey?.trim().isNotEmpty == true
|
||||
? _runtime.snapshot.mainSessionKey!.trim()
|
||||
: 'main';
|
||||
final destinationChanged = _destination != WorkspaceDestination.assistant;
|
||||
final homeDestination =
|
||||
capabilities.supportsDestination(WorkspaceDestination.assistant)
|
||||
? WorkspaceDestination.assistant
|
||||
: (capabilities.allowedDestinations.isEmpty
|
||||
? WorkspaceDestination.assistant
|
||||
: capabilities.allowedDestinations.first);
|
||||
final destinationChanged = _destination != homeDestination;
|
||||
final detailChanged = _detailPanel != null;
|
||||
final settingsDrillInChanged =
|
||||
_settingsDetail != null || _settingsNavigationContext != null;
|
||||
_destination = WorkspaceDestination.assistant;
|
||||
_destination = homeDestination;
|
||||
_settingsDetail = null;
|
||||
_settingsNavigationContext = null;
|
||||
_detailPanel = null;
|
||||
@ -761,6 +786,9 @@ class AppController extends ChangeNotifier {
|
||||
final destination = tab == ModulesTab.agents
|
||||
? WorkspaceDestination.agents
|
||||
: WorkspaceDestination.nodes;
|
||||
if (!capabilities.supportsDestination(destination)) {
|
||||
return;
|
||||
}
|
||||
final changed =
|
||||
_destination != destination ||
|
||||
_modulesTab != tab ||
|
||||
@ -787,6 +815,9 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void openSecrets({SecretsTab tab = SecretsTab.vault}) {
|
||||
if (!capabilities.supportsDestination(WorkspaceDestination.secrets)) {
|
||||
return;
|
||||
}
|
||||
final changed =
|
||||
_destination != WorkspaceDestination.secrets ||
|
||||
_secretsTab != tab ||
|
||||
@ -813,6 +844,9 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void openAiGateway({AiGatewayTab tab = AiGatewayTab.models}) {
|
||||
if (!capabilities.supportsDestination(WorkspaceDestination.aiGateway)) {
|
||||
return;
|
||||
}
|
||||
final changed =
|
||||
_destination != WorkspaceDestination.aiGateway ||
|
||||
_aiGatewayTab != tab ||
|
||||
@ -843,11 +877,18 @@ class AppController extends ChangeNotifier {
|
||||
SettingsDetailPage? detail,
|
||||
SettingsNavigationContext? navigationContext,
|
||||
}) {
|
||||
final resolvedTab = detail?.tab ?? tab;
|
||||
if (!capabilities.supportsDestination(WorkspaceDestination.settings)) {
|
||||
return;
|
||||
}
|
||||
final requestedTab = detail?.tab ?? tab;
|
||||
final resolvedTab = _sanitizeSettingsTab(requestedTab);
|
||||
final resolvedDetail = detail != null && resolvedTab == detail.tab
|
||||
? detail
|
||||
: null;
|
||||
final changed =
|
||||
_destination != WorkspaceDestination.settings ||
|
||||
_settingsTab != resolvedTab ||
|
||||
_settingsDetail != detail ||
|
||||
_settingsDetail != resolvedDetail ||
|
||||
_settingsNavigationContext != navigationContext ||
|
||||
_detailPanel != null;
|
||||
if (!changed) {
|
||||
@ -855,21 +896,24 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
_destination = WorkspaceDestination.settings;
|
||||
_settingsTab = resolvedTab;
|
||||
_settingsDetail = detail;
|
||||
_settingsNavigationContext = navigationContext;
|
||||
_settingsDetail = resolvedDetail;
|
||||
_settingsNavigationContext = resolvedDetail == null
|
||||
? null
|
||||
: navigationContext;
|
||||
_detailPanel = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setSettingsTab(SettingsTab tab, {bool clearDetail = true}) {
|
||||
final resolvedTab = _sanitizeSettingsTab(tab);
|
||||
final changed =
|
||||
_settingsTab != tab ||
|
||||
_settingsTab != resolvedTab ||
|
||||
(clearDetail &&
|
||||
(_settingsDetail != null || _settingsNavigationContext != null));
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
_settingsTab = tab;
|
||||
_settingsTab = resolvedTab;
|
||||
if (clearDetail) {
|
||||
_settingsDetail = null;
|
||||
_settingsNavigationContext = null;
|
||||
@ -1235,20 +1279,21 @@ class AppController extends ChangeNotifier {
|
||||
Future<void> setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget target,
|
||||
) async {
|
||||
final resolvedTarget = _sanitizeExecutionTarget(target);
|
||||
final currentTarget = assistantExecutionTargetForSession(
|
||||
_sessionsController.currentSessionKey,
|
||||
);
|
||||
if (currentTarget == target &&
|
||||
settings.assistantExecutionTarget == target) {
|
||||
if (currentTarget == resolvedTarget &&
|
||||
settings.assistantExecutionTarget == resolvedTarget) {
|
||||
return;
|
||||
}
|
||||
_upsertAssistantThreadRecord(
|
||||
_sessionsController.currentSessionKey,
|
||||
executionTarget: target,
|
||||
executionTarget: resolvedTarget,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
await _applyAssistantExecutionTarget(
|
||||
target,
|
||||
resolvedTarget,
|
||||
sessionKey: _sessionsController.currentSessionKey,
|
||||
persistDefaultSelection: true,
|
||||
);
|
||||
@ -1291,6 +1336,7 @@ class AppController extends ChangeNotifier {
|
||||
required String sessionKey,
|
||||
required bool persistDefaultSelection,
|
||||
}) async {
|
||||
final resolvedTarget = _sanitizeExecutionTarget(target);
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
if (!matchesSessionKey(
|
||||
normalizedSessionKey,
|
||||
@ -1299,14 +1345,14 @@ class AppController extends ChangeNotifier {
|
||||
await _sessionsController.switchSession(normalizedSessionKey);
|
||||
}
|
||||
if (persistDefaultSelection &&
|
||||
settings.assistantExecutionTarget != target) {
|
||||
settings.assistantExecutionTarget != resolvedTarget) {
|
||||
await saveSettings(
|
||||
settings.copyWith(assistantExecutionTarget: target),
|
||||
settings.copyWith(assistantExecutionTarget: resolvedTarget),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
}
|
||||
|
||||
if (target == AssistantExecutionTarget.aiGatewayOnly) {
|
||||
if (resolvedTarget == AssistantExecutionTarget.aiGatewayOnly) {
|
||||
if (_runtime.isConnected) {
|
||||
_preserveGatewayHistoryForSession(normalizedSessionKey);
|
||||
}
|
||||
@ -1325,7 +1371,9 @@ class AppController extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
final targetProfile = _gatewayProfileForAssistantExecutionTarget(target);
|
||||
final targetProfile = _gatewayProfileForAssistantExecutionTarget(
|
||||
resolvedTarget,
|
||||
);
|
||||
try {
|
||||
await _connectProfile(targetProfile);
|
||||
} catch (_) {
|
||||
@ -1509,8 +1557,10 @@ class AppController extends ChangeNotifier {
|
||||
bool refreshAfterSave = true,
|
||||
}) async {
|
||||
final current = settings;
|
||||
final sanitized = _sanitizeMultiAgentSettings(
|
||||
_sanitizeOllamaCloudSettings(_sanitizeCodeAgentSettings(snapshot)),
|
||||
final sanitized = _sanitizeFeatureFlagSettings(
|
||||
_sanitizeMultiAgentSettings(
|
||||
_sanitizeOllamaCloudSettings(_sanitizeCodeAgentSettings(snapshot)),
|
||||
),
|
||||
);
|
||||
setActiveAppLanguage(sanitized.appLanguage);
|
||||
await _settingsController.saveSnapshot(sanitized);
|
||||
@ -1602,6 +1652,9 @@ class AppController extends ChangeNotifier {
|
||||
if (!kAssistantNavigationDestinationCandidates.contains(destination)) {
|
||||
return;
|
||||
}
|
||||
if (!capabilities.supportsDestination(destination)) {
|
||||
return;
|
||||
}
|
||||
final current = assistantNavigationDestinations;
|
||||
final next = current.contains(destination)
|
||||
? current.where((item) => item != destination).toList(growable: false)
|
||||
@ -1765,9 +1818,11 @@ class AppController extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
}
|
||||
final normalized = _sanitizeMultiAgentSettings(
|
||||
_sanitizeOllamaCloudSettings(
|
||||
_sanitizeCodeAgentSettings(_settingsController.snapshot),
|
||||
final normalized = _sanitizeFeatureFlagSettings(
|
||||
_sanitizeMultiAgentSettings(
|
||||
_sanitizeOllamaCloudSettings(
|
||||
_sanitizeCodeAgentSettings(_settingsController.snapshot),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (normalized.toJsonString() !=
|
||||
@ -1909,6 +1964,45 @@ class AppController extends ChangeNotifier {
|
||||
return snapshot.copyWith(multiAgent: resolved);
|
||||
}
|
||||
|
||||
SettingsSnapshot _sanitizeFeatureFlagSettings(SettingsSnapshot snapshot) {
|
||||
final features = featuresFor(_hostUiFeaturePlatform);
|
||||
final allowedNavigation = normalizeAssistantNavigationDestinations(
|
||||
snapshot.assistantNavigationDestinations,
|
||||
).where(features.allowedDestinations.contains).toList(growable: false);
|
||||
final sanitizedExecutionTarget = features.sanitizeExecutionTarget(
|
||||
snapshot.assistantExecutionTarget,
|
||||
);
|
||||
final multiAgentConfig = features.supportsMultiAgent
|
||||
? snapshot.multiAgent
|
||||
: snapshot.multiAgent.copyWith(enabled: false);
|
||||
final experimentalCanvas =
|
||||
features.allowsExperimentalSetting(
|
||||
UiFeatureKeys.settingsExperimentalCanvas,
|
||||
)
|
||||
? snapshot.experimentalCanvas
|
||||
: false;
|
||||
final experimentalBridge =
|
||||
features.allowsExperimentalSetting(
|
||||
UiFeatureKeys.settingsExperimentalBridge,
|
||||
)
|
||||
? snapshot.experimentalBridge
|
||||
: false;
|
||||
final experimentalDebug =
|
||||
features.allowsExperimentalSetting(
|
||||
UiFeatureKeys.settingsExperimentalDebug,
|
||||
)
|
||||
? snapshot.experimentalDebug
|
||||
: false;
|
||||
return snapshot.copyWith(
|
||||
assistantExecutionTarget: sanitizedExecutionTarget,
|
||||
assistantNavigationDestinations: allowedNavigation,
|
||||
multiAgent: multiAgentConfig,
|
||||
experimentalCanvas: experimentalCanvas,
|
||||
experimentalBridge: experimentalBridge,
|
||||
experimentalDebug: experimentalDebug,
|
||||
);
|
||||
}
|
||||
|
||||
SettingsSnapshot _sanitizeOllamaCloudSettings(SettingsSnapshot snapshot) {
|
||||
final rawBaseUrl = snapshot.ollamaCloud.baseUrl.trim();
|
||||
final normalized = rawBaseUrl.endsWith('/')
|
||||
@ -1922,6 +2016,16 @@ class AppController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
SettingsTab _sanitizeSettingsTab(SettingsTab tab) {
|
||||
return featuresFor(_hostUiFeaturePlatform).sanitizeSettingsTab(tab);
|
||||
}
|
||||
|
||||
AssistantExecutionTarget _sanitizeExecutionTarget(
|
||||
AssistantExecutionTarget? target,
|
||||
) {
|
||||
return featuresFor(_hostUiFeaturePlatform).sanitizeExecutionTarget(target);
|
||||
}
|
||||
|
||||
MultiAgentConfig _resolveMultiAgentConfig(SettingsSnapshot snapshot) {
|
||||
final defaults = MultiAgentConfig.defaults();
|
||||
final current = snapshot.multiAgent;
|
||||
|
||||
@ -9,13 +9,16 @@ import '../web/web_ai_gateway_client.dart';
|
||||
import '../web/web_relay_gateway_client.dart';
|
||||
import '../web/web_store.dart';
|
||||
import 'app_capabilities.dart';
|
||||
import 'ui_feature_manifest.dart';
|
||||
|
||||
class AppController extends ChangeNotifier {
|
||||
AppController({
|
||||
WebStore? store,
|
||||
WebAiGatewayClient? aiGatewayClient,
|
||||
WebRelayGatewayClient? relayClient,
|
||||
UiFeatureManifest? uiFeatureManifest,
|
||||
}) : _store = store ?? WebStore(),
|
||||
_uiFeatureManifest = uiFeatureManifest ?? UiFeatureManifest.fallback(),
|
||||
_aiGatewayClient = aiGatewayClient ?? const WebAiGatewayClient() {
|
||||
_relayClient = relayClient ?? WebRelayGatewayClient(_store);
|
||||
_relayEventsSubscription = _relayClient.events.listen(_handleRelayEvent);
|
||||
@ -23,6 +26,7 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final WebStore _store;
|
||||
final UiFeatureManifest _uiFeatureManifest;
|
||||
final WebAiGatewayClient _aiGatewayClient;
|
||||
late final WebRelayGatewayClient _relayClient;
|
||||
|
||||
@ -43,7 +47,9 @@ class AppController extends ChangeNotifier {
|
||||
String _currentSessionKey = '';
|
||||
String? _lastAssistantError;
|
||||
|
||||
AppCapabilities get capabilities => AppCapabilities.web;
|
||||
UiFeatureManifest get uiFeatureManifest => _uiFeatureManifest;
|
||||
AppCapabilities get capabilities =>
|
||||
AppCapabilities.fromFeatureAccess(featuresFor(UiFeaturePlatform.web));
|
||||
WorkspaceDestination get destination => _destination;
|
||||
SettingsTab get settingsTab => _settingsTab;
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
@ -74,6 +80,10 @@ class AppController extends ChangeNotifier {
|
||||
String _relayPasswordCache = '';
|
||||
String _aiGatewayApiKeyCache = '';
|
||||
|
||||
UiFeatureAccess featuresFor(UiFeaturePlatform platform) {
|
||||
return _uiFeatureManifest.forPlatform(platform);
|
||||
}
|
||||
|
||||
AssistantExecutionTarget get assistantExecutionTarget =>
|
||||
_currentRecord.executionTarget ?? _settings.assistantExecutionTarget;
|
||||
AssistantExecutionTarget get currentAssistantExecutionTarget =>
|
||||
@ -112,9 +122,7 @@ class AppController extends ChangeNotifier {
|
||||
updatedAtMs:
|
||||
record.updatedAtMs ??
|
||||
DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
executionTarget:
|
||||
_sanitizeTarget(record.executionTarget) ??
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
executionTarget: _sanitizeTarget(record.executionTarget),
|
||||
pending: _pendingSessionKeys.contains(record.sessionKey),
|
||||
current: record.sessionKey == _currentSessionKey,
|
||||
),
|
||||
@ -201,9 +209,7 @@ class AppController extends ChangeNotifier {
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
final target =
|
||||
_sanitizeTarget(_settings.assistantExecutionTarget) ??
|
||||
AssistantExecutionTarget.aiGatewayOnly;
|
||||
final target = _sanitizeTarget(_settings.assistantExecutionTarget);
|
||||
final record = _newRecord(target: target);
|
||||
_threadRecords[record.sessionKey] = record;
|
||||
_currentSessionKey = record.sessionKey;
|
||||
@ -248,10 +254,19 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void navigateHome() {
|
||||
navigateTo(WorkspaceDestination.assistant);
|
||||
final homeDestination =
|
||||
capabilities.supportsDestination(WorkspaceDestination.assistant)
|
||||
? WorkspaceDestination.assistant
|
||||
: (capabilities.allowedDestinations.isEmpty
|
||||
? WorkspaceDestination.assistant
|
||||
: capabilities.allowedDestinations.first);
|
||||
navigateTo(homeDestination);
|
||||
}
|
||||
|
||||
void openSettings({SettingsTab tab = SettingsTab.general}) {
|
||||
if (!capabilities.supportsDestination(WorkspaceDestination.settings)) {
|
||||
return;
|
||||
}
|
||||
_destination = WorkspaceDestination.settings;
|
||||
_settingsTab = _sanitizeSettingsTab(tab);
|
||||
notifyListeners();
|
||||
@ -281,8 +296,7 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> createConversation({AssistantExecutionTarget? target}) async {
|
||||
final resolvedTarget =
|
||||
_sanitizeTarget(target) ?? _settings.assistantExecutionTarget;
|
||||
final resolvedTarget = _sanitizeTarget(target);
|
||||
final record = _newRecord(target: resolvedTarget);
|
||||
_threadRecords[record.sessionKey] = record;
|
||||
_currentSessionKey = record.sessionKey;
|
||||
@ -309,8 +323,7 @@ class AppController extends ChangeNotifier {
|
||||
Future<void> setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget target,
|
||||
) async {
|
||||
final resolvedTarget =
|
||||
_sanitizeTarget(target) ?? AssistantExecutionTarget.aiGatewayOnly;
|
||||
final resolvedTarget = _sanitizeTarget(target);
|
||||
_settings = _settings.copyWith(assistantExecutionTarget: resolvedTarget);
|
||||
_replaceCurrentRecord(
|
||||
_currentRecord.copyWith(executionTarget: resolvedTarget),
|
||||
@ -657,19 +670,11 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
SettingsTab _sanitizeSettingsTab(SettingsTab tab) {
|
||||
return switch (tab) {
|
||||
SettingsTab.workspace ||
|
||||
SettingsTab.agents ||
|
||||
SettingsTab.diagnostics ||
|
||||
SettingsTab.experimental => SettingsTab.gateway,
|
||||
_ => tab,
|
||||
};
|
||||
return featuresFor(UiFeaturePlatform.web).sanitizeSettingsTab(tab);
|
||||
}
|
||||
|
||||
SettingsSnapshot _sanitizeSettings(SettingsSnapshot snapshot) {
|
||||
final target =
|
||||
_sanitizeTarget(snapshot.assistantExecutionTarget) ??
|
||||
AssistantExecutionTarget.aiGatewayOnly;
|
||||
final target = _sanitizeTarget(snapshot.assistantExecutionTarget);
|
||||
return snapshot.copyWith(
|
||||
assistantExecutionTarget: target,
|
||||
gateway: snapshot.gateway.copyWith(
|
||||
@ -683,9 +688,7 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
AssistantThreadRecord _sanitizeRecord(AssistantThreadRecord record) {
|
||||
final target =
|
||||
_sanitizeTarget(record.executionTarget) ??
|
||||
AssistantExecutionTarget.aiGatewayOnly;
|
||||
final target = _sanitizeTarget(record.executionTarget);
|
||||
return record.copyWith(
|
||||
executionTarget: target,
|
||||
title: record.title.trim().isEmpty
|
||||
@ -694,13 +697,8 @@ class AppController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
AssistantExecutionTarget? _sanitizeTarget(AssistantExecutionTarget? target) {
|
||||
return switch (target) {
|
||||
AssistantExecutionTarget.remote => AssistantExecutionTarget.remote,
|
||||
AssistantExecutionTarget.aiGatewayOnly =>
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
_ => AssistantExecutionTarget.aiGatewayOnly,
|
||||
};
|
||||
AssistantExecutionTarget _sanitizeTarget(AssistantExecutionTarget? target) {
|
||||
return featuresFor(UiFeaturePlatform.web).sanitizeExecutionTarget(target);
|
||||
}
|
||||
|
||||
AssistantThreadRecord _newRecord({
|
||||
|
||||
@ -2,6 +2,7 @@ const kSystemAppName = 'XWorkmate';
|
||||
const kProductBrandName = 'XWorkmate';
|
||||
const kProductSubtitle = 'Actionable AI Workspace';
|
||||
const kProductTagline = 'Turn Ideas Into Action';
|
||||
const kProductLogoAsset = 'assets/branding/xmate_desktop_logo.png';
|
||||
const kAppVersion = String.fromEnvironment(
|
||||
'XWORKMATE_DISPLAY_VERSION',
|
||||
defaultValue: '2026.3.11',
|
||||
|
||||
@ -89,6 +89,15 @@ class _AppShellState extends State<AppShell> {
|
||||
controller.destination == WorkspaceDestination.account
|
||||
? WorkspaceDestination.assistant
|
||||
: controller.destination;
|
||||
final availableMobileDestinations = _mobileDestinations
|
||||
.where(controller.capabilities.supportsDestination)
|
||||
.toList(growable: false);
|
||||
final resolvedMobileDestination =
|
||||
availableMobileDestinations.contains(mobileDestination)
|
||||
? mobileDestination
|
||||
: (availableMobileDestinations.isEmpty
|
||||
? mobileDestination
|
||||
: availableMobileDestinations.first);
|
||||
|
||||
void openMobileDetail(DetailPanelData detail) {
|
||||
showModalBottomSheet<void>(
|
||||
@ -151,7 +160,7 @@ class _AppShellState extends State<AppShell> {
|
||||
child: Container(
|
||||
color: palette.canvas.withValues(alpha: 0.18),
|
||||
child: _pageForDestination(
|
||||
mobileDestination,
|
||||
resolvedMobileDestination,
|
||||
openMobileDetail,
|
||||
),
|
||||
),
|
||||
@ -163,15 +172,18 @@ class _AppShellState extends State<AppShell> {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: NavigationBar(
|
||||
selectedIndex: _mobileDestinations.indexOf(
|
||||
mobileDestination,
|
||||
),
|
||||
selectedIndex:
|
||||
availableMobileDestinations.isEmpty
|
||||
? 0
|
||||
: availableMobileDestinations.indexOf(
|
||||
resolvedMobileDestination,
|
||||
),
|
||||
onDestinationSelected: (index) {
|
||||
controller.navigateTo(
|
||||
_mobileDestinations[index],
|
||||
availableMobileDestinations[index],
|
||||
);
|
||||
},
|
||||
destinations: _mobileDestinations
|
||||
destinations: availableMobileDestinations
|
||||
.map(
|
||||
(destination) => NavigationDestination(
|
||||
icon: Icon(destination.icon),
|
||||
@ -187,10 +199,15 @@ class _AppShellState extends State<AppShell> {
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: 96,
|
||||
child: FloatingActionButton.small(
|
||||
onPressed: openAccountSheet,
|
||||
child: const Icon(Icons.account_circle_rounded),
|
||||
),
|
||||
child:
|
||||
controller.capabilities.supportsDestination(
|
||||
WorkspaceDestination.account,
|
||||
)
|
||||
? FloatingActionButton.small(
|
||||
onPressed: openAccountSheet,
|
||||
child: const Icon(Icons.account_circle_rounded),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -242,6 +259,8 @@ class _AppShellState extends State<AppShell> {
|
||||
.toSet(),
|
||||
onToggleFavorite:
|
||||
controller.toggleAssistantNavigationDestination,
|
||||
availableDestinations:
|
||||
controller.capabilities.allowedDestinations,
|
||||
),
|
||||
if (sidebarState == AppSidebarState.expanded &&
|
||||
!embedSidebarIntoAssistant)
|
||||
|
||||
@ -6,6 +6,7 @@ import '../theme/app_palette.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../web/web_assistant_page.dart';
|
||||
import '../web/web_settings_page.dart';
|
||||
import '../widgets/app_brand_logo.dart';
|
||||
import 'app_controller_web.dart';
|
||||
|
||||
class AppShell extends StatelessWidget {
|
||||
@ -18,6 +19,19 @@ class AppShell extends StatelessWidget {
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
final availableDestinations =
|
||||
<WorkspaceDestination>[
|
||||
WorkspaceDestination.assistant,
|
||||
WorkspaceDestination.settings,
|
||||
]
|
||||
.where(controller.capabilities.supportsDestination)
|
||||
.toList(growable: false);
|
||||
final currentDestination =
|
||||
availableDestinations.contains(controller.destination)
|
||||
? controller.destination
|
||||
: (availableDestinations.isEmpty
|
||||
? WorkspaceDestination.assistant
|
||||
: availableDestinations.first);
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
@ -27,34 +41,33 @@ class AppShell extends StatelessWidget {
|
||||
if (mobile) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(child: _buildPage(controller)),
|
||||
Expanded(
|
||||
child: _buildPage(
|
||||
controller,
|
||||
destination: currentDestination,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: NavigationBar(
|
||||
selectedIndex:
|
||||
controller.destination ==
|
||||
WorkspaceDestination.settings
|
||||
? 1
|
||||
: 0,
|
||||
selectedIndex: availableDestinations.indexOf(
|
||||
currentDestination,
|
||||
),
|
||||
onDestinationSelected: (index) {
|
||||
controller.navigateTo(
|
||||
index == 0
|
||||
? WorkspaceDestination.assistant
|
||||
: WorkspaceDestination.settings,
|
||||
availableDestinations[index],
|
||||
);
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline_rounded),
|
||||
label: 'Assistant',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.tune_rounded),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
destinations: availableDestinations
|
||||
.map(
|
||||
(destination) => NavigationDestination(
|
||||
icon: Icon(destination.icon),
|
||||
label: destination.label,
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -66,9 +79,7 @@ class AppShell extends StatelessWidget {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width:
|
||||
controller.destination ==
|
||||
WorkspaceDestination.settings
|
||||
width: currentDestination == WorkspaceDestination.settings
|
||||
? 248
|
||||
: 236,
|
||||
margin: const EdgeInsets.fromLTRB(4, 4, 4, 0),
|
||||
@ -92,18 +103,7 @@ class AppShell extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: palette.accentMuted,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.crop_square_rounded,
|
||||
color: palette.accent,
|
||||
),
|
||||
),
|
||||
const AppBrandLogo(size: 32, borderRadius: 10),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@ -137,23 +137,15 @@ class AppShell extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_WebNavItem(
|
||||
destination: WorkspaceDestination.assistant,
|
||||
selected:
|
||||
controller.destination ==
|
||||
WorkspaceDestination.assistant,
|
||||
onTap: () => controller.navigateTo(
|
||||
WorkspaceDestination.assistant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_WebNavItem(
|
||||
destination: WorkspaceDestination.settings,
|
||||
selected:
|
||||
controller.destination ==
|
||||
WorkspaceDestination.settings,
|
||||
onTap: () => controller.navigateTo(
|
||||
WorkspaceDestination.settings,
|
||||
...availableDestinations.map(
|
||||
(destination) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _WebNavItem(
|
||||
destination: destination,
|
||||
selected: currentDestination == destination,
|
||||
onTap: () =>
|
||||
controller.navigateTo(destination),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
@ -191,7 +183,12 @@ class AppShell extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildPage(controller)),
|
||||
Expanded(
|
||||
child: _buildPage(
|
||||
controller,
|
||||
destination: currentDestination,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@ -202,8 +199,11 @@ class AppShell extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPage(AppController controller) {
|
||||
return switch (controller.destination) {
|
||||
Widget _buildPage(
|
||||
AppController controller, {
|
||||
required WorkspaceDestination destination,
|
||||
}) {
|
||||
return switch (destination) {
|
||||
WorkspaceDestination.settings => WebSettingsPage(controller: controller),
|
||||
_ => WebAssistantPage(controller: controller),
|
||||
};
|
||||
|
||||
995
lib/app/ui_feature_manifest.dart
Normal file
@ -0,0 +1,995 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/runtime_models.dart';
|
||||
|
||||
enum UiFeaturePlatform { mobile, desktop, web }
|
||||
|
||||
enum UiFeatureReleaseTier { stable, beta, experimental }
|
||||
|
||||
enum UiFeatureBuildMode { debug, profile, release }
|
||||
|
||||
UiFeatureBuildMode currentUiFeatureBuildMode() {
|
||||
if (kReleaseMode) {
|
||||
return UiFeatureBuildMode.release;
|
||||
}
|
||||
if (kProfileMode) {
|
||||
return UiFeatureBuildMode.profile;
|
||||
}
|
||||
return UiFeatureBuildMode.debug;
|
||||
}
|
||||
|
||||
UiFeaturePlatform resolveUiFeaturePlatformFromContext(BuildContext context) {
|
||||
if (kIsWeb) {
|
||||
return UiFeaturePlatform.web;
|
||||
}
|
||||
final platform = Theme.of(context).platform;
|
||||
if (platform == TargetPlatform.iOS || platform == TargetPlatform.android) {
|
||||
return UiFeaturePlatform.mobile;
|
||||
}
|
||||
return UiFeaturePlatform.desktop;
|
||||
}
|
||||
|
||||
abstract final class UiFeatureKeys {
|
||||
static const navigationAssistant = 'navigation.assistant';
|
||||
static const navigationTasks = 'navigation.tasks';
|
||||
static const navigationWorkspace = 'navigation.workspace';
|
||||
static const navigationSkills = 'navigation.skills';
|
||||
static const navigationNodes = 'navigation.nodes';
|
||||
static const navigationAgents = 'navigation.agents';
|
||||
static const navigationMcpServer = 'navigation.mcp_server';
|
||||
static const navigationClawHub = 'navigation.claw_hub';
|
||||
static const navigationSecrets = 'navigation.secrets';
|
||||
static const navigationAiGateway = 'navigation.ai_gateway';
|
||||
static const navigationSettings = 'navigation.settings';
|
||||
static const navigationAccount = 'navigation.account';
|
||||
|
||||
static const workspaceSkills = 'workspace.skills';
|
||||
static const workspaceNodes = 'workspace.nodes';
|
||||
static const workspaceAgents = 'workspace.agents';
|
||||
static const workspaceMcpServer = 'workspace.mcp_server';
|
||||
static const workspaceClawHub = 'workspace.claw_hub';
|
||||
static const workspaceAiGateway = 'workspace.ai_gateway';
|
||||
static const workspaceAccount = 'workspace.account';
|
||||
|
||||
static const assistantDirectAi = 'assistant.direct_ai';
|
||||
static const assistantLocalGateway = 'assistant.local_gateway';
|
||||
static const assistantRelayGateway = 'assistant.relay_gateway';
|
||||
static const assistantFileAttachments = 'assistant.file_attachments';
|
||||
static const assistantMultiAgent = 'assistant.multi_agent';
|
||||
static const assistantLocalRuntime = 'assistant.local_runtime';
|
||||
|
||||
static const settingsGeneral = 'settings.general';
|
||||
static const settingsWorkspace = 'settings.workspace';
|
||||
static const settingsGateway = 'settings.gateway';
|
||||
static const settingsAgents = 'settings.agents';
|
||||
static const settingsAppearance = 'settings.appearance';
|
||||
static const settingsDiagnostics = 'settings.diagnostics';
|
||||
static const settingsExperimental = 'settings.experimental';
|
||||
static const settingsAbout = 'settings.about';
|
||||
static const settingsExperimentalCanvas = 'settings.experimental_canvas';
|
||||
static const settingsExperimentalBridge = 'settings.experimental_bridge';
|
||||
static const settingsExperimentalDebug = 'settings.experimental_debug';
|
||||
}
|
||||
|
||||
@immutable
|
||||
class UiFeatureFlag {
|
||||
const UiFeatureFlag({
|
||||
required this.enabled,
|
||||
required this.releaseTier,
|
||||
required this.buildModes,
|
||||
required this.description,
|
||||
required this.uiSurface,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final UiFeatureReleaseTier releaseTier;
|
||||
final Set<UiFeatureBuildMode> buildModes;
|
||||
final String description;
|
||||
final String uiSurface;
|
||||
|
||||
UiFeatureFlag copyWith({
|
||||
bool? enabled,
|
||||
UiFeatureReleaseTier? releaseTier,
|
||||
Set<UiFeatureBuildMode>? buildModes,
|
||||
String? description,
|
||||
String? uiSurface,
|
||||
}) {
|
||||
return UiFeatureFlag(
|
||||
enabled: enabled ?? this.enabled,
|
||||
releaseTier: releaseTier ?? this.releaseTier,
|
||||
buildModes: buildModes ?? this.buildModes,
|
||||
description: description ?? this.description,
|
||||
uiSurface: uiSurface ?? this.uiSurface,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UiFeatureManifest {
|
||||
UiFeatureManifest._({
|
||||
required this.releasePolicy,
|
||||
required Map<UiFeaturePlatform, Map<String, Map<String, UiFeatureFlag>>>
|
||||
flagsByPlatform,
|
||||
}) : _flagsByPlatform = flagsByPlatform;
|
||||
|
||||
static const String assetPath = 'config/feature_flags.yaml';
|
||||
|
||||
static const String fallbackYaml = '''
|
||||
release_policy:
|
||||
debug: [stable, beta, experimental]
|
||||
profile: [stable, beta]
|
||||
release: [stable]
|
||||
|
||||
mobile:
|
||||
navigation:
|
||||
assistant:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile assistant destination
|
||||
ui_surface: mobile_shell
|
||||
tasks:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile tasks destination
|
||||
ui_surface: mobile_shell
|
||||
workspace:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace hub destination
|
||||
ui_surface: mobile_shell
|
||||
secrets:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile secrets destination
|
||||
ui_surface: mobile_shell
|
||||
settings:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings destination
|
||||
ui_surface: mobile_shell
|
||||
workspace:
|
||||
skills:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace skills launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
nodes:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace nodes launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
agents:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace agents launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
mcp_server:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace MCP launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
claw_hub:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace ClawHub launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
ai_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace AI Gateway launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
account:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile workspace account launcher
|
||||
ui_surface: mobile_workspace_hub
|
||||
assistant:
|
||||
direct_ai:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile direct AI assistant mode
|
||||
ui_surface: assistant_page
|
||||
local_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile local gateway assistant mode
|
||||
ui_surface: assistant_page
|
||||
relay_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile relay gateway assistant mode
|
||||
ui_surface: assistant_page
|
||||
file_attachments:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile file attachment action in assistant composer
|
||||
ui_surface: assistant_page
|
||||
multi_agent:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile multi-agent toggle in assistant composer
|
||||
ui_surface: assistant_page
|
||||
local_runtime:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Mobile does not expose desktop runtime controls
|
||||
ui_surface: assistant_page
|
||||
settings:
|
||||
general:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings general tab
|
||||
ui_surface: settings_page
|
||||
workspace:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings workspace tab
|
||||
ui_surface: settings_page
|
||||
gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings gateway tab
|
||||
ui_surface: settings_page
|
||||
agents:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings multi-agent tab
|
||||
ui_surface: settings_page
|
||||
appearance:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings appearance tab
|
||||
ui_surface: settings_page
|
||||
diagnostics:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings diagnostics tab
|
||||
ui_surface: settings_page
|
||||
experimental:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings experimental tab
|
||||
ui_surface: settings_page
|
||||
about:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings about tab
|
||||
ui_surface: settings_page
|
||||
experimental_canvas:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile experimental canvas host toggle
|
||||
ui_surface: settings_page
|
||||
experimental_bridge:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile experimental bridge toggle
|
||||
ui_surface: settings_page
|
||||
experimental_debug:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile experimental debug runtime toggle
|
||||
ui_surface: settings_page
|
||||
|
||||
desktop:
|
||||
navigation:
|
||||
assistant:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop assistant destination
|
||||
ui_surface: sidebar_navigation
|
||||
tasks:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop tasks destination
|
||||
ui_surface: sidebar_navigation
|
||||
skills:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop skills destination
|
||||
ui_surface: sidebar_navigation
|
||||
nodes:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop nodes destination
|
||||
ui_surface: sidebar_navigation
|
||||
agents:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop agents destination
|
||||
ui_surface: sidebar_navigation
|
||||
mcp_server:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop MCP Hub destination
|
||||
ui_surface: sidebar_navigation
|
||||
claw_hub:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop ClawHub destination
|
||||
ui_surface: sidebar_navigation
|
||||
secrets:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop secrets destination
|
||||
ui_surface: sidebar_navigation
|
||||
ai_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop AI Gateway destination
|
||||
ui_surface: sidebar_navigation
|
||||
settings:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings destination
|
||||
ui_surface: sidebar_navigation
|
||||
account:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop account destination
|
||||
ui_surface: sidebar_navigation
|
||||
assistant:
|
||||
direct_ai:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop direct AI assistant mode
|
||||
ui_surface: assistant_page
|
||||
local_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop local gateway assistant mode
|
||||
ui_surface: assistant_page
|
||||
relay_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop relay gateway assistant mode
|
||||
ui_surface: assistant_page
|
||||
file_attachments:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop file attachment action in assistant composer
|
||||
ui_surface: assistant_page
|
||||
multi_agent:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop multi-agent toggle in assistant composer
|
||||
ui_surface: assistant_page
|
||||
local_runtime:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop local runtime and gateway orchestration entry
|
||||
ui_surface: assistant_page
|
||||
settings:
|
||||
general:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings general tab
|
||||
ui_surface: settings_page
|
||||
workspace:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings workspace tab
|
||||
ui_surface: settings_page
|
||||
gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings gateway tab
|
||||
ui_surface: settings_page
|
||||
agents:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings multi-agent tab
|
||||
ui_surface: settings_page
|
||||
appearance:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings appearance tab
|
||||
ui_surface: settings_page
|
||||
diagnostics:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings diagnostics tab
|
||||
ui_surface: settings_page
|
||||
experimental:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings experimental tab
|
||||
ui_surface: settings_page
|
||||
about:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings about tab
|
||||
ui_surface: settings_page
|
||||
experimental_canvas:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop experimental canvas host toggle
|
||||
ui_surface: settings_page
|
||||
experimental_bridge:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop experimental bridge toggle
|
||||
ui_surface: settings_page
|
||||
experimental_debug:
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop experimental debug runtime toggle
|
||||
ui_surface: settings_page
|
||||
|
||||
web:
|
||||
navigation:
|
||||
assistant:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web assistant destination
|
||||
ui_surface: web_shell
|
||||
settings:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web settings destination
|
||||
ui_surface: web_shell
|
||||
assistant:
|
||||
direct_ai:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web direct AI assistant mode
|
||||
ui_surface: web_assistant_page
|
||||
relay_gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web relay gateway assistant mode
|
||||
ui_surface: web_assistant_page
|
||||
file_attachments:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose file attachments in assistant composer
|
||||
ui_surface: web_assistant_page
|
||||
multi_agent:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose multi-agent assistant toggle
|
||||
ui_surface: web_assistant_page
|
||||
local_gateway:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose local gateway assistant mode
|
||||
ui_surface: web_assistant_page
|
||||
local_runtime:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose desktop runtime controls
|
||||
ui_surface: web_assistant_page
|
||||
settings:
|
||||
general:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web settings general tab
|
||||
ui_surface: web_settings_page
|
||||
gateway:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web settings gateway tab
|
||||
ui_surface: web_settings_page
|
||||
appearance:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web settings appearance tab
|
||||
ui_surface: web_settings_page
|
||||
about:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web settings about tab
|
||||
ui_surface: web_settings_page
|
||||
''';
|
||||
|
||||
final Map<UiFeatureBuildMode, Set<UiFeatureReleaseTier>> releasePolicy;
|
||||
final Map<UiFeaturePlatform, Map<String, Map<String, UiFeatureFlag>>>
|
||||
_flagsByPlatform;
|
||||
|
||||
factory UiFeatureManifest.fromYamlString(String raw) {
|
||||
final root = loadYaml(raw);
|
||||
if (root is! YamlMap) {
|
||||
throw const FormatException('Feature manifest root must be a YAML map.');
|
||||
}
|
||||
final releasePolicy = _parseReleasePolicy(root['release_policy']);
|
||||
final flagsByPlatform =
|
||||
<UiFeaturePlatform, Map<String, Map<String, UiFeatureFlag>>>{};
|
||||
for (final platform in UiFeaturePlatform.values) {
|
||||
flagsByPlatform[platform] = _parsePlatformModules(
|
||||
platform: platform,
|
||||
raw: root[platform.name],
|
||||
);
|
||||
}
|
||||
return UiFeatureManifest._(
|
||||
releasePolicy: releasePolicy,
|
||||
flagsByPlatform: flagsByPlatform,
|
||||
);
|
||||
}
|
||||
|
||||
factory UiFeatureManifest.fallback() {
|
||||
return UiFeatureManifest.fromYamlString(fallbackYaml);
|
||||
}
|
||||
|
||||
UiFeatureAccess forPlatform(
|
||||
UiFeaturePlatform platform, {
|
||||
UiFeatureBuildMode? buildMode,
|
||||
}) {
|
||||
return UiFeatureAccess._(
|
||||
manifest: this,
|
||||
platform: platform,
|
||||
buildMode: buildMode ?? currentUiFeatureBuildMode(),
|
||||
);
|
||||
}
|
||||
|
||||
UiFeatureFlag? lookup(
|
||||
UiFeaturePlatform platform,
|
||||
String module,
|
||||
String feature,
|
||||
) {
|
||||
return _flagsByPlatform[platform]?[module]?[feature];
|
||||
}
|
||||
|
||||
UiFeatureManifest copyWithFeature({
|
||||
required UiFeaturePlatform platform,
|
||||
required String module,
|
||||
required String feature,
|
||||
bool? enabled,
|
||||
UiFeatureReleaseTier? releaseTier,
|
||||
Set<UiFeatureBuildMode>? buildModes,
|
||||
String? description,
|
||||
String? uiSurface,
|
||||
}) {
|
||||
final current = lookup(platform, module, feature);
|
||||
if (current == null) {
|
||||
throw StateError('Unknown feature: ${platform.name}.$module.$feature');
|
||||
}
|
||||
final updated = current.copyWith(
|
||||
enabled: enabled,
|
||||
releaseTier: releaseTier,
|
||||
buildModes: buildModes,
|
||||
description: description,
|
||||
uiSurface: uiSurface,
|
||||
);
|
||||
final nextPlatforms =
|
||||
<UiFeaturePlatform, Map<String, Map<String, UiFeatureFlag>>>{};
|
||||
for (final entry in _flagsByPlatform.entries) {
|
||||
nextPlatforms[entry.key] = entry.value.map(
|
||||
(moduleName, features) => MapEntry(
|
||||
moduleName,
|
||||
features.map((featureName, flag) => MapEntry(featureName, flag)),
|
||||
),
|
||||
);
|
||||
}
|
||||
nextPlatforms[platform]![module]![feature] = updated;
|
||||
return UiFeatureManifest._(
|
||||
releasePolicy: releasePolicy,
|
||||
flagsByPlatform: nextPlatforms,
|
||||
);
|
||||
}
|
||||
|
||||
static Map<UiFeatureBuildMode, Set<UiFeatureReleaseTier>> _parseReleasePolicy(
|
||||
Object? raw,
|
||||
) {
|
||||
if (raw is! YamlMap) {
|
||||
throw const FormatException(
|
||||
'release_policy must define debug/profile/release tiers.',
|
||||
);
|
||||
}
|
||||
final policy = <UiFeatureBuildMode, Set<UiFeatureReleaseTier>>{};
|
||||
for (final mode in UiFeatureBuildMode.values) {
|
||||
final rawValue = raw[mode.name];
|
||||
if (rawValue is! YamlList) {
|
||||
throw FormatException(
|
||||
'release_policy.${mode.name} must be a list of tiers.',
|
||||
);
|
||||
}
|
||||
policy[mode] = rawValue
|
||||
.map((value) => _parseReleaseTier(value, context: mode.name))
|
||||
.toSet();
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
static Map<String, Map<String, UiFeatureFlag>> _parsePlatformModules({
|
||||
required UiFeaturePlatform platform,
|
||||
required Object? raw,
|
||||
}) {
|
||||
if (raw is! YamlMap) {
|
||||
throw FormatException('${platform.name} must be a YAML map.');
|
||||
}
|
||||
final modules = <String, Map<String, UiFeatureFlag>>{};
|
||||
for (final entry in raw.entries) {
|
||||
final moduleName = '${entry.key}'.trim();
|
||||
if (moduleName.isEmpty) {
|
||||
throw FormatException('${platform.name} contains an empty module key.');
|
||||
}
|
||||
final rawModule = entry.value;
|
||||
if (rawModule is! YamlMap) {
|
||||
throw FormatException('${platform.name}.$moduleName must be a map.');
|
||||
}
|
||||
final features = <String, UiFeatureFlag>{};
|
||||
for (final featureEntry in rawModule.entries) {
|
||||
final featureName = '${featureEntry.key}'.trim();
|
||||
if (featureName.isEmpty) {
|
||||
throw FormatException(
|
||||
'${platform.name}.$moduleName contains an empty feature key.',
|
||||
);
|
||||
}
|
||||
features[featureName] = _parseFeatureFlag(
|
||||
platform: platform,
|
||||
moduleName: moduleName,
|
||||
featureName: featureName,
|
||||
raw: featureEntry.value,
|
||||
);
|
||||
}
|
||||
modules[moduleName] = features;
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
|
||||
static UiFeatureFlag _parseFeatureFlag({
|
||||
required UiFeaturePlatform platform,
|
||||
required String moduleName,
|
||||
required String featureName,
|
||||
required Object? raw,
|
||||
}) {
|
||||
if (raw is! YamlMap) {
|
||||
throw FormatException(
|
||||
'${platform.name}.$moduleName.$featureName must be a map.',
|
||||
);
|
||||
}
|
||||
const allowedKeys = <String>{
|
||||
'enabled',
|
||||
'release_tier',
|
||||
'build_modes',
|
||||
'description',
|
||||
'ui_surface',
|
||||
};
|
||||
for (final key in raw.keys) {
|
||||
final name = '$key';
|
||||
if (!allowedKeys.contains(name)) {
|
||||
throw FormatException(
|
||||
'Unsupported key "$name" in '
|
||||
'${platform.name}.$moduleName.$featureName.',
|
||||
);
|
||||
}
|
||||
}
|
||||
final enabled = raw['enabled'];
|
||||
final releaseTier = raw['release_tier'];
|
||||
final buildModes = raw['build_modes'];
|
||||
final description = raw['description'];
|
||||
final uiSurface = raw['ui_surface'];
|
||||
if (enabled is! bool) {
|
||||
throw FormatException(
|
||||
'${platform.name}.$moduleName.$featureName.enabled must be bool.',
|
||||
);
|
||||
}
|
||||
if (buildModes is! YamlList) {
|
||||
throw FormatException(
|
||||
'${platform.name}.$moduleName.$featureName.build_modes must be a list.',
|
||||
);
|
||||
}
|
||||
if (description is! String || description.trim().isEmpty) {
|
||||
throw FormatException(
|
||||
'${platform.name}.$moduleName.$featureName.description is required.',
|
||||
);
|
||||
}
|
||||
if (uiSurface is! String || uiSurface.trim().isEmpty) {
|
||||
throw FormatException(
|
||||
'${platform.name}.$moduleName.$featureName.ui_surface is required.',
|
||||
);
|
||||
}
|
||||
return UiFeatureFlag(
|
||||
enabled: enabled,
|
||||
releaseTier: _parseReleaseTier(
|
||||
releaseTier,
|
||||
context: '${platform.name}.$moduleName.$featureName',
|
||||
),
|
||||
buildModes: buildModes
|
||||
.map(
|
||||
(value) => _parseBuildMode(
|
||||
value,
|
||||
context: '${platform.name}.$moduleName.$featureName',
|
||||
),
|
||||
)
|
||||
.toSet(),
|
||||
description: description.trim(),
|
||||
uiSurface: uiSurface.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
static UiFeatureReleaseTier _parseReleaseTier(
|
||||
Object? raw, {
|
||||
required String context,
|
||||
}) {
|
||||
final value = '$raw'.trim();
|
||||
return UiFeatureReleaseTier.values.firstWhere(
|
||||
(item) => item.name == value,
|
||||
orElse: () {
|
||||
throw FormatException('Unknown release tier "$value" at $context.');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static UiFeatureBuildMode _parseBuildMode(
|
||||
Object? raw, {
|
||||
required String context,
|
||||
}) {
|
||||
final value = '$raw'.trim();
|
||||
return UiFeatureBuildMode.values.firstWhere(
|
||||
(item) => item.name == value,
|
||||
orElse: () {
|
||||
throw FormatException('Unknown build mode "$value" at $context.');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UiFeatureAccess {
|
||||
UiFeatureAccess._({
|
||||
required UiFeatureManifest manifest,
|
||||
required this.platform,
|
||||
required this.buildMode,
|
||||
}) : _manifest = manifest;
|
||||
|
||||
final UiFeatureManifest _manifest;
|
||||
final UiFeaturePlatform platform;
|
||||
final UiFeatureBuildMode buildMode;
|
||||
|
||||
static const Map<UiFeaturePlatform, Map<String, WorkspaceDestination>>
|
||||
_destinationMappings = <UiFeaturePlatform, Map<String, WorkspaceDestination>>{
|
||||
UiFeaturePlatform.mobile: <String, WorkspaceDestination>{
|
||||
UiFeatureKeys.navigationAssistant: WorkspaceDestination.assistant,
|
||||
UiFeatureKeys.navigationTasks: WorkspaceDestination.tasks,
|
||||
UiFeatureKeys.navigationSecrets: WorkspaceDestination.secrets,
|
||||
UiFeatureKeys.navigationSettings: WorkspaceDestination.settings,
|
||||
UiFeatureKeys.workspaceSkills: WorkspaceDestination.skills,
|
||||
UiFeatureKeys.workspaceNodes: WorkspaceDestination.nodes,
|
||||
UiFeatureKeys.workspaceAgents: WorkspaceDestination.agents,
|
||||
UiFeatureKeys.workspaceMcpServer: WorkspaceDestination.mcpServer,
|
||||
UiFeatureKeys.workspaceClawHub: WorkspaceDestination.clawHub,
|
||||
UiFeatureKeys.workspaceAiGateway: WorkspaceDestination.aiGateway,
|
||||
UiFeatureKeys.workspaceAccount: WorkspaceDestination.account,
|
||||
},
|
||||
UiFeaturePlatform.desktop: <String, WorkspaceDestination>{
|
||||
UiFeatureKeys.navigationAssistant: WorkspaceDestination.assistant,
|
||||
UiFeatureKeys.navigationTasks: WorkspaceDestination.tasks,
|
||||
UiFeatureKeys.navigationSkills: WorkspaceDestination.skills,
|
||||
UiFeatureKeys.navigationNodes: WorkspaceDestination.nodes,
|
||||
UiFeatureKeys.navigationAgents: WorkspaceDestination.agents,
|
||||
UiFeatureKeys.navigationMcpServer: WorkspaceDestination.mcpServer,
|
||||
UiFeatureKeys.navigationClawHub: WorkspaceDestination.clawHub,
|
||||
UiFeatureKeys.navigationSecrets: WorkspaceDestination.secrets,
|
||||
UiFeatureKeys.navigationAiGateway: WorkspaceDestination.aiGateway,
|
||||
UiFeatureKeys.navigationSettings: WorkspaceDestination.settings,
|
||||
UiFeatureKeys.navigationAccount: WorkspaceDestination.account,
|
||||
},
|
||||
UiFeaturePlatform.web: <String, WorkspaceDestination>{
|
||||
UiFeatureKeys.navigationAssistant: WorkspaceDestination.assistant,
|
||||
UiFeatureKeys.navigationSettings: WorkspaceDestination.settings,
|
||||
},
|
||||
};
|
||||
|
||||
static const Map<String, SettingsTab> _settingsTabMappings =
|
||||
<String, SettingsTab>{
|
||||
UiFeatureKeys.settingsGeneral: SettingsTab.general,
|
||||
UiFeatureKeys.settingsWorkspace: SettingsTab.workspace,
|
||||
UiFeatureKeys.settingsGateway: SettingsTab.gateway,
|
||||
UiFeatureKeys.settingsAgents: SettingsTab.agents,
|
||||
UiFeatureKeys.settingsAppearance: SettingsTab.appearance,
|
||||
UiFeatureKeys.settingsDiagnostics: SettingsTab.diagnostics,
|
||||
UiFeatureKeys.settingsExperimental: SettingsTab.experimental,
|
||||
UiFeatureKeys.settingsAbout: SettingsTab.about,
|
||||
};
|
||||
|
||||
bool isEnabledPath(String path) {
|
||||
final parts = path.split('.');
|
||||
if (parts.length != 2) {
|
||||
throw ArgumentError.value(path, 'path', 'Expected module.feature');
|
||||
}
|
||||
return isEnabled(parts[0], parts[1]);
|
||||
}
|
||||
|
||||
bool isEnabled(String module, String feature) {
|
||||
final flag = _manifest.lookup(platform, module, feature);
|
||||
if (flag == null || !flag.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (!flag.buildModes.contains(buildMode)) {
|
||||
return false;
|
||||
}
|
||||
final allowedTiers = _manifest.releasePolicy[buildMode] ?? const {};
|
||||
return allowedTiers.contains(flag.releaseTier);
|
||||
}
|
||||
|
||||
Set<WorkspaceDestination> get allowedDestinations {
|
||||
final mappings = _destinationMappings[platform] ?? const {};
|
||||
final allowed = <WorkspaceDestination>{};
|
||||
for (final entry in mappings.entries) {
|
||||
if (isEnabledPath(entry.key)) {
|
||||
allowed.add(entry.value);
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
bool get showsWorkspaceHub =>
|
||||
platform == UiFeaturePlatform.mobile &&
|
||||
isEnabledPath(UiFeatureKeys.navigationWorkspace);
|
||||
|
||||
bool get supportsDirectAi => isEnabledPath(UiFeatureKeys.assistantDirectAi);
|
||||
|
||||
bool get supportsLocalGateway =>
|
||||
isEnabledPath(UiFeatureKeys.assistantLocalGateway);
|
||||
|
||||
bool get supportsRelayGateway =>
|
||||
isEnabledPath(UiFeatureKeys.assistantRelayGateway);
|
||||
|
||||
bool get supportsFileAttachments =>
|
||||
isEnabledPath(UiFeatureKeys.assistantFileAttachments);
|
||||
|
||||
bool get supportsMultiAgent =>
|
||||
isEnabledPath(UiFeatureKeys.assistantMultiAgent);
|
||||
|
||||
bool get supportsDesktopRuntime =>
|
||||
platform == UiFeaturePlatform.desktop &&
|
||||
isEnabledPath(UiFeatureKeys.assistantLocalRuntime);
|
||||
|
||||
bool get supportsDiagnostics =>
|
||||
isEnabledPath(UiFeatureKeys.settingsDiagnostics);
|
||||
|
||||
List<SettingsTab> get availableSettingsTabs {
|
||||
return SettingsTab.values
|
||||
.where(
|
||||
(tab) => _settingsTabMappings.entries.any(
|
||||
(entry) => entry.value == tab && isEnabledPath(entry.key),
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
SettingsTab sanitizeSettingsTab(SettingsTab tab) {
|
||||
final available = availableSettingsTabs;
|
||||
if (available.contains(tab)) {
|
||||
return tab;
|
||||
}
|
||||
if (available.isNotEmpty) {
|
||||
return available.first;
|
||||
}
|
||||
return SettingsTab.general;
|
||||
}
|
||||
|
||||
bool allowsExperimentalSetting(String keyPath) {
|
||||
return isEnabledPath(keyPath);
|
||||
}
|
||||
|
||||
List<AssistantExecutionTarget> get availableExecutionTargets {
|
||||
final targets = <AssistantExecutionTarget>[];
|
||||
if (supportsDirectAi) {
|
||||
targets.add(AssistantExecutionTarget.aiGatewayOnly);
|
||||
}
|
||||
if (supportsLocalGateway) {
|
||||
targets.add(AssistantExecutionTarget.local);
|
||||
}
|
||||
if (supportsRelayGateway) {
|
||||
targets.add(AssistantExecutionTarget.remote);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
AssistantExecutionTarget sanitizeExecutionTarget(
|
||||
AssistantExecutionTarget? target,
|
||||
) {
|
||||
final available = availableExecutionTargets;
|
||||
if (target != null && available.contains(target)) {
|
||||
return target;
|
||||
}
|
||||
final preferredOrder = platform == UiFeaturePlatform.web
|
||||
? const <AssistantExecutionTarget>[
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
AssistantExecutionTarget.remote,
|
||||
]
|
||||
: const <AssistantExecutionTarget>[
|
||||
AssistantExecutionTarget.local,
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
AssistantExecutionTarget.remote,
|
||||
];
|
||||
for (final candidate in preferredOrder) {
|
||||
if (available.contains(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return platform == UiFeaturePlatform.web
|
||||
? AssistantExecutionTarget.aiGatewayOnly
|
||||
: AssistantExecutionTarget.local;
|
||||
}
|
||||
}
|
||||
|
||||
class UiFeatureManifestLoader {
|
||||
const UiFeatureManifestLoader._();
|
||||
|
||||
static Future<UiFeatureManifest> load({
|
||||
AssetBundle? assetBundle,
|
||||
String assetPath = UiFeatureManifest.assetPath,
|
||||
}) async {
|
||||
final bundle = assetBundle ?? rootBundle;
|
||||
try {
|
||||
final raw = await bundle.loadString(assetPath);
|
||||
return UiFeatureManifest.fromYamlString(raw);
|
||||
} catch (_) {
|
||||
return UiFeatureManifest.fallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ import 'package:markdown/markdown.dart' as md;
|
||||
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../app/app_metadata.dart';
|
||||
import '../../app/ui_feature_manifest.dart';
|
||||
import '../../i18n/app_language.dart';
|
||||
import '../../models/app_models.dart';
|
||||
import '../../runtime/multi_agent_orchestrator.dart';
|
||||
@ -524,6 +525,12 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
}
|
||||
|
||||
Future<void> _pickAttachments() async {
|
||||
final uiFeatures = widget.controller.featuresFor(
|
||||
resolveUiFeaturePlatformFromContext(context),
|
||||
);
|
||||
if (!uiFeatures.supportsFileAttachments) {
|
||||
return;
|
||||
}
|
||||
final files = await openFiles(
|
||||
acceptedTypeGroups: const [
|
||||
XTypeGroup(
|
||||
@ -551,6 +558,9 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
|
||||
Future<void> _submitPrompt() async {
|
||||
final controller = widget.controller;
|
||||
final uiFeatures = controller.featuresFor(
|
||||
resolveUiFeaturePlatformFromContext(context),
|
||||
);
|
||||
final settings = controller.settings;
|
||||
final executionTarget = controller.assistantExecutionTarget;
|
||||
final rawPrompt = _inputController.text.trim();
|
||||
@ -608,7 +618,8 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
);
|
||||
});
|
||||
|
||||
if (controller.settings.multiAgent.enabled) {
|
||||
if (uiFeatures.supportsMultiAgent &&
|
||||
controller.settings.multiAgent.enabled) {
|
||||
final collaborationAttachments = _attachments
|
||||
.map(
|
||||
(item) => CollaborationAttachment(
|
||||
@ -2386,6 +2397,9 @@ class _ComposerBarState extends State<_ComposerBar> {
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final controller = widget.controller;
|
||||
final uiFeatures = controller.featuresFor(
|
||||
resolveUiFeaturePlatformFromContext(context),
|
||||
);
|
||||
final aiGatewayOnly = controller.isAiGatewayOnlyMode;
|
||||
final connected = aiGatewayOnly
|
||||
? controller.canUseAiGatewayConversation
|
||||
@ -2417,37 +2431,39 @@ class _ComposerBarState extends State<_ComposerBar> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
PopupMenuButton<String>(
|
||||
key: const Key('assistant-attachment-menu-button'),
|
||||
tooltip: appText('添加文件等', 'Add files'),
|
||||
offset: const Offset(0, 48),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'attach':
|
||||
widget.onPickAttachments();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem<String>(
|
||||
value: 'attach',
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.attach_file_rounded),
|
||||
title: Text('添加照片和文件'),
|
||||
if (uiFeatures.supportsFileAttachments) ...[
|
||||
PopupMenuButton<String>(
|
||||
key: const Key('assistant-attachment-menu-button'),
|
||||
tooltip: appText('添加文件等', 'Add files'),
|
||||
offset: const Offset(0, 48),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'attach':
|
||||
widget.onPickAttachments();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem<String>(
|
||||
value: 'attach',
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.attach_file_rounded),
|
||||
title: Text('添加照片和文件'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const _ComposerIconButton(icon: Icons.add_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
child: const _ComposerIconButton(icon: Icons.add_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
PopupMenuButton<AssistantExecutionTarget>(
|
||||
key: const Key('assistant-execution-target-button'),
|
||||
tooltip: appText('任务对话模式', 'Task Dialog Mode'),
|
||||
onSelected: (value) {
|
||||
controller.setAssistantExecutionTarget(value);
|
||||
},
|
||||
itemBuilder: (context) => AssistantExecutionTarget.values
|
||||
itemBuilder: (context) => uiFeatures.availableExecutionTargets
|
||||
.map(
|
||||
(value) => PopupMenuItem<AssistantExecutionTarget>(
|
||||
value: value,
|
||||
@ -2475,62 +2491,65 @@ class _ComposerBarState extends State<_ComposerBar> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message: appText(
|
||||
'多 Agent 协作模式(Architect 调度/文档 → Lead Engineer 主程 → Worker/Review)',
|
||||
'Multi-Agent Collaboration Mode (Architect docs/scheduler -> Lead Engineer -> Worker/Review)',
|
||||
if (uiFeatures.supportsMultiAgent) ...[
|
||||
Tooltip(
|
||||
message: appText(
|
||||
'多 Agent 协作模式(Architect 调度/文档 → Lead Engineer 主程 → Worker/Review)',
|
||||
'Multi-Agent Collaboration Mode (Architect docs/scheduler -> Lead Engineer -> Worker/Review)',
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: controller.multiAgentOrchestrator,
|
||||
builder: (context, _) {
|
||||
final collab = controller.multiAgentOrchestrator;
|
||||
final enabled = collab.config.enabled;
|
||||
return IconButton(
|
||||
key: const Key('assistant-collaboration-toggle'),
|
||||
icon: Icon(
|
||||
enabled
|
||||
? Icons.auto_awesome
|
||||
: Icons.auto_awesome_outlined,
|
||||
size: 20,
|
||||
color: enabled ? Colors.orange : null,
|
||||
),
|
||||
onPressed:
|
||||
collab.isRunning ||
|
||||
controller.isMultiAgentRunPending
|
||||
? null
|
||||
: () => unawaited(
|
||||
controller.saveMultiAgentConfig(
|
||||
collab.config.copyWith(enabled: !enabled),
|
||||
),
|
||||
),
|
||||
splashRadius: 18,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
AnimatedBuilder(
|
||||
animation: controller.multiAgentOrchestrator,
|
||||
builder: (context, _) {
|
||||
final collab = controller.multiAgentOrchestrator;
|
||||
final enabled = collab.config.enabled;
|
||||
return IconButton(
|
||||
key: const Key('assistant-collaboration-toggle'),
|
||||
icon: Icon(
|
||||
enabled
|
||||
? Icons.auto_awesome
|
||||
: Icons.auto_awesome_outlined,
|
||||
size: 20,
|
||||
color: enabled ? Colors.orange : null,
|
||||
if (!collab.config.enabled) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: _ComposerToolbarChip(
|
||||
icon: Icons.hub_rounded,
|
||||
label: collab.config.usesAris
|
||||
? appText('ARIS', 'ARIS')
|
||||
: appText('原生', 'Native'),
|
||||
showChevron: false,
|
||||
maxLabelWidth: 64,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 6,
|
||||
),
|
||||
),
|
||||
onPressed:
|
||||
collab.isRunning || controller.isMultiAgentRunPending
|
||||
? null
|
||||
: () => unawaited(
|
||||
controller.saveMultiAgentConfig(
|
||||
collab.config.copyWith(enabled: !enabled),
|
||||
),
|
||||
),
|
||||
splashRadius: 18,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: controller.multiAgentOrchestrator,
|
||||
builder: (context, _) {
|
||||
final collab = controller.multiAgentOrchestrator;
|
||||
if (!collab.config.enabled) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: _ComposerToolbarChip(
|
||||
icon: Icons.hub_rounded,
|
||||
label: collab.config.usesAris
|
||||
? appText('ARIS', 'ARIS')
|
||||
: appText('原生', 'Native'),
|
||||
showChevron: false,
|
||||
maxLabelWidth: 64,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 6,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../app/ui_feature_manifest.dart';
|
||||
import '../../app/workspace_page_registry.dart';
|
||||
import '../../i18n/app_language.dart';
|
||||
import '../../models/app_models.dart';
|
||||
@ -189,7 +190,8 @@ class _MobileShellState extends State<MobileShell> {
|
||||
}
|
||||
|
||||
Widget _buildCurrentPage() {
|
||||
if (_showWorkspaceHub) {
|
||||
final features = widget.controller.featuresFor(UiFeaturePlatform.mobile);
|
||||
if (_showWorkspaceHub && features.showsWorkspaceHub) {
|
||||
return _MobileWorkspaceLauncher(
|
||||
controller: widget.controller,
|
||||
onOpenGatewayConnect: _showConnectSheet,
|
||||
@ -211,9 +213,26 @@ class _MobileShellState extends State<MobileShell> {
|
||||
return AnimatedBuilder(
|
||||
animation: widget.controller,
|
||||
builder: (context, _) {
|
||||
final features = widget.controller.featuresFor(
|
||||
UiFeaturePlatform.mobile,
|
||||
);
|
||||
final availableTabs = <MobileShellTab>[
|
||||
if (features.isEnabledPath(UiFeatureKeys.navigationAssistant))
|
||||
MobileShellTab.assistant,
|
||||
if (features.isEnabledPath(UiFeatureKeys.navigationTasks))
|
||||
MobileShellTab.tasks,
|
||||
if (features.showsWorkspaceHub) MobileShellTab.workspace,
|
||||
if (features.isEnabledPath(UiFeatureKeys.navigationSecrets))
|
||||
MobileShellTab.secrets,
|
||||
if (features.isEnabledPath(UiFeatureKeys.navigationSettings))
|
||||
MobileShellTab.settings,
|
||||
];
|
||||
final currentTab = _showWorkspaceHub
|
||||
? MobileShellTab.workspace
|
||||
: _tabForDestination(widget.controller.destination);
|
||||
final resolvedCurrentTab = availableTabs.contains(currentTab)
|
||||
? currentTab
|
||||
: (availableTabs.isEmpty ? currentTab : availableTabs.first);
|
||||
final destinationKey = _showWorkspaceHub
|
||||
? const ValueKey<String>('mobile-shell-workspace')
|
||||
: ValueKey<String>(
|
||||
@ -260,7 +279,9 @@ class _MobileShellState extends State<MobileShell> {
|
||||
const SizedBox(height: 10),
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sidebar),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppRadius.sidebar,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
@ -291,7 +312,8 @@ class _MobileShellState extends State<MobileShell> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 12, 6, 18),
|
||||
child: _BottomPillNav(
|
||||
currentTab: currentTab,
|
||||
currentTab: resolvedCurrentTab,
|
||||
tabs: availableTabs,
|
||||
onChanged: _selectTab,
|
||||
),
|
||||
),
|
||||
@ -408,8 +430,8 @@ class _MobileSafeStrip extends StatelessWidget {
|
||||
color: connection.status == RuntimeConnectionStatus.connected
|
||||
? palette.success
|
||||
: palette.textSecondary,
|
||||
background: connection.status ==
|
||||
RuntimeConnectionStatus.connected
|
||||
background:
|
||||
connection.status == RuntimeConnectionStatus.connected
|
||||
? palette.success.withValues(alpha: 0.14)
|
||||
: palette.surfaceSecondary,
|
||||
),
|
||||
@ -611,11 +633,13 @@ class _MobileSafeSheet extends StatelessWidget {
|
||||
_MobileFactChip(
|
||||
icon: Icons.monitor_heart_outlined,
|
||||
label: connection.status.label,
|
||||
color: connection.status ==
|
||||
color:
|
||||
connection.status ==
|
||||
RuntimeConnectionStatus.connected
|
||||
? palette.success
|
||||
: palette.textSecondary,
|
||||
background: connection.status ==
|
||||
background:
|
||||
connection.status ==
|
||||
RuntimeConnectionStatus.connected
|
||||
? palette.success.withValues(alpha: 0.14)
|
||||
: palette.surfaceSecondary,
|
||||
@ -665,18 +689,13 @@ class _MobileSafeSheet extends StatelessWidget {
|
||||
child: Text(
|
||||
controller.canQuickConnectGateway
|
||||
? appText('快速连接', 'Quick Connect')
|
||||
: appText(
|
||||
'打开连接面板',
|
||||
'Open Connection',
|
||||
),
|
||||
: appText('打开连接面板', 'Open Connection'),
|
||||
),
|
||||
),
|
||||
if (hasPendingRun)
|
||||
FilledButton.tonal(
|
||||
onPressed: controller.abortRun,
|
||||
child: Text(
|
||||
appText('停止运行', 'Stop Run'),
|
||||
),
|
||||
child: Text(appText('停止运行', 'Stop Run')),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -968,7 +987,8 @@ class _MobilePendingApprovalCard extends StatelessWidget {
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton.tonal(
|
||||
onPressed: () => controller.approveDevicePairing(item.requestId),
|
||||
onPressed: () =>
|
||||
controller.approveDevicePairing(item.requestId),
|
||||
child: Text(appText('批准配对', 'Approve Pairing')),
|
||||
),
|
||||
OutlinedButton(
|
||||
@ -996,10 +1016,7 @@ class _MobilePendingApprovalCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _MobilePairedDeviceCard extends StatelessWidget {
|
||||
const _MobilePairedDeviceCard({
|
||||
required this.controller,
|
||||
required this.item,
|
||||
});
|
||||
const _MobilePairedDeviceCard({required this.controller, required this.item});
|
||||
|
||||
final AppController controller;
|
||||
final GatewayPairedDevice item;
|
||||
@ -1121,13 +1138,14 @@ String _mobileSecurePathLabel({
|
||||
: connection.mode;
|
||||
return switch (mode) {
|
||||
RuntimeConnectionMode.local => appText('Loopback WS', 'Loopback WS'),
|
||||
RuntimeConnectionMode.remote => profile.tls
|
||||
? appText('Secure Direct TLS', 'Secure Direct TLS')
|
||||
: appText('Remote Non-TLS', 'Remote Non-TLS'),
|
||||
RuntimeConnectionMode.remote =>
|
||||
profile.tls
|
||||
? appText('Secure Direct TLS', 'Secure Direct TLS')
|
||||
: appText('Remote Non-TLS', 'Remote Non-TLS'),
|
||||
RuntimeConnectionMode.unconfigured => appText(
|
||||
'Gateway 未配置',
|
||||
'Gateway Not Configured',
|
||||
),
|
||||
'Gateway 未配置',
|
||||
'Gateway Not Configured',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1178,50 +1196,60 @@ class _MobileWorkspaceLauncher extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final connection = controller.connection;
|
||||
final palette = context.palette;
|
||||
final entries = <_WorkspaceEntry>[
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.skills,
|
||||
subtitle: appText('技能包与依赖状态', 'Packages and dependency status'),
|
||||
iconColor: palette.accent,
|
||||
iconBackground: palette.accentMuted,
|
||||
),
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.nodes,
|
||||
subtitle: appText('边缘节点与实例', 'Edge nodes and instances'),
|
||||
iconColor: _tealLine,
|
||||
iconBackground: _tealSoft,
|
||||
),
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.agents,
|
||||
subtitle: appText('代理运行态与配置', 'Agent state and configuration'),
|
||||
iconColor: palette.warning,
|
||||
iconBackground: palette.warning.withValues(alpha: 0.12),
|
||||
),
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.mcpServer,
|
||||
subtitle: appText('MCP 连接与工具注册', 'MCP endpoints and tools'),
|
||||
iconColor: palette.accent,
|
||||
iconBackground: palette.accentMuted,
|
||||
),
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.clawHub,
|
||||
subtitle: appText('技能与模板市场', 'Marketplace and templates'),
|
||||
iconColor: _violetLine,
|
||||
iconBackground: _violetSoft,
|
||||
),
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.aiGateway,
|
||||
subtitle: appText('模型与代理网关', 'Models and agent gateway'),
|
||||
iconColor: palette.accent,
|
||||
iconBackground: palette.accentMuted,
|
||||
),
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.account,
|
||||
subtitle: appText('身份、工作区与会话', 'Identity, workspace and sessions'),
|
||||
iconColor: palette.success,
|
||||
iconBackground: palette.success.withValues(alpha: 0.12),
|
||||
),
|
||||
];
|
||||
final features = controller.featuresFor(UiFeaturePlatform.mobile);
|
||||
final entries =
|
||||
<_WorkspaceEntry>[
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.skills,
|
||||
subtitle: appText('技能包与依赖状态', 'Packages and dependency status'),
|
||||
iconColor: palette.accent,
|
||||
iconBackground: palette.accentMuted,
|
||||
),
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.nodes,
|
||||
subtitle: appText('边缘节点与实例', 'Edge nodes and instances'),
|
||||
iconColor: _tealLine,
|
||||
iconBackground: _tealSoft,
|
||||
),
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.agents,
|
||||
subtitle: appText('代理运行态与配置', 'Agent state and configuration'),
|
||||
iconColor: palette.warning,
|
||||
iconBackground: palette.warning.withValues(alpha: 0.12),
|
||||
),
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.mcpServer,
|
||||
subtitle: appText('MCP 连接与工具注册', 'MCP endpoints and tools'),
|
||||
iconColor: palette.accent,
|
||||
iconBackground: palette.accentMuted,
|
||||
),
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.clawHub,
|
||||
subtitle: appText('技能与模板市场', 'Marketplace and templates'),
|
||||
iconColor: _violetLine,
|
||||
iconBackground: _violetSoft,
|
||||
),
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.aiGateway,
|
||||
subtitle: appText('模型与代理网关', 'Models and agent gateway'),
|
||||
iconColor: palette.accent,
|
||||
iconBackground: palette.accentMuted,
|
||||
),
|
||||
_WorkspaceEntry(
|
||||
destination: WorkspaceDestination.account,
|
||||
subtitle: appText(
|
||||
'身份、工作区与会话',
|
||||
'Identity, workspace and sessions',
|
||||
),
|
||||
iconColor: palette.success,
|
||||
iconBackground: palette.success.withValues(alpha: 0.12),
|
||||
),
|
||||
]
|
||||
.where(
|
||||
(entry) =>
|
||||
features.allowedDestinations.contains(entry.destination),
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 12),
|
||||
@ -1598,9 +1626,14 @@ class _GradientActionButton extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _BottomPillNav extends StatelessWidget {
|
||||
const _BottomPillNav({required this.currentTab, required this.onChanged});
|
||||
const _BottomPillNav({
|
||||
required this.currentTab,
|
||||
required this.tabs,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final MobileShellTab currentTab;
|
||||
final List<MobileShellTab> tabs;
|
||||
final ValueChanged<MobileShellTab> onChanged;
|
||||
|
||||
@override
|
||||
@ -1615,7 +1648,7 @@ class _BottomPillNav extends StatelessWidget {
|
||||
boxShadow: [palette.chromeShadowAmbient],
|
||||
),
|
||||
child: Row(
|
||||
children: MobileShellTab.values
|
||||
children: tabs
|
||||
.map(
|
||||
(tab) => Expanded(
|
||||
child: GestureDetector(
|
||||
@ -1645,12 +1678,13 @@ class _BottomPillNav extends StatelessWidget {
|
||||
tab.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: currentTab == tab
|
||||
? palette.accent
|
||||
: palette.textPrimary,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: currentTab == tab
|
||||
? palette.accent
|
||||
: palette.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../app/app_metadata.dart';
|
||||
import '../../app/ui_feature_manifest.dart';
|
||||
import '../../app/workspace_navigation.dart';
|
||||
import '../ai_gateway/ai_gateway_page.dart';
|
||||
import '../../i18n/app_language.dart';
|
||||
@ -108,7 +109,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
_tab = controller.settingsTab;
|
||||
final featurePlatform = resolveUiFeaturePlatformFromContext(context);
|
||||
final uiFeatures = controller.featuresFor(featurePlatform);
|
||||
final availableTabs = uiFeatures.availableSettingsTabs;
|
||||
_tab = uiFeatures.sanitizeSettingsTab(controller.settingsTab);
|
||||
_detail = controller.settingsDetail;
|
||||
_navigationContext = controller.settingsNavigationContext;
|
||||
final settings = controller.settings;
|
||||
@ -160,10 +164,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
const SizedBox(height: 24),
|
||||
if (!showingDetail) ...[
|
||||
SectionTabs(
|
||||
items: SettingsTab.values.map((item) => item.label).toList(),
|
||||
items: availableTabs.map((item) => item.label).toList(),
|
||||
value: _tab.label,
|
||||
onChanged: (value) => setState(() {
|
||||
_tab = SettingsTab.values.firstWhere(
|
||||
_tab = availableTabs.firstWhere(
|
||||
(item) => item.label == value,
|
||||
);
|
||||
_detail = null;
|
||||
@ -173,7 +177,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
..._buildContentForCurrentState(context, controller, settings),
|
||||
..._buildContentForCurrentState(
|
||||
context,
|
||||
controller,
|
||||
settings,
|
||||
uiFeatures,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -185,6 +194,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
SettingsSnapshot settings,
|
||||
UiFeatureAccess uiFeatures,
|
||||
) {
|
||||
if (_detail != null) {
|
||||
return _buildDetailContent(context, controller, settings, _detail!);
|
||||
@ -201,6 +211,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
context,
|
||||
controller,
|
||||
settings,
|
||||
uiFeatures,
|
||||
),
|
||||
SettingsTab.about => _buildAbout(context, controller),
|
||||
};
|
||||
@ -1589,7 +1600,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_AgentRoleCard(
|
||||
title: '🧭 ${appText('Architect(调度/文档)', 'Architect (Docs / Scheduler)')}',
|
||||
title:
|
||||
'🧭 ${appText('Architect(调度/文档)', 'Architect (Docs / Scheduler)')}',
|
||||
description: appText(
|
||||
'负责 requirements -> acceptance evidence、架构选项排序、文档与调度。',
|
||||
'Owns requirements -> acceptance evidence, option ranking, docs, and orchestration.',
|
||||
@ -1597,10 +1609,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
cliTool: config.architect.cliTool,
|
||||
model: config.architect.model,
|
||||
enabled: config.architect.enabled,
|
||||
cliOptions: _mergeOptions(
|
||||
config.architect.cliTool,
|
||||
const ['claude', 'codex', 'opencode', 'gemini'],
|
||||
),
|
||||
cliOptions: _mergeOptions(config.architect.cliTool, const [
|
||||
'claude',
|
||||
'codex',
|
||||
'opencode',
|
||||
'gemini',
|
||||
]),
|
||||
modelOptions: _getArchitectModelOptions(settings, config),
|
||||
onCliChanged: (tool) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
@ -1631,10 +1645,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
cliTool: config.engineer.cliTool,
|
||||
model: config.engineer.model,
|
||||
enabled: config.engineer.enabled,
|
||||
cliOptions: _mergeOptions(
|
||||
config.engineer.cliTool,
|
||||
const ['codex', 'claude', 'opencode', 'gemini'],
|
||||
),
|
||||
cliOptions: _mergeOptions(config.engineer.cliTool, const [
|
||||
'codex',
|
||||
'claude',
|
||||
'opencode',
|
||||
'gemini',
|
||||
]),
|
||||
modelOptions: _getLeadModelOptions(settings, config),
|
||||
onCliChanged: (tool) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
@ -1657,7 +1673,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_AgentRoleCard(
|
||||
title: '🧪 ${appText('Worker/Review(Worker 池)', 'Worker/Review Pool')}',
|
||||
title:
|
||||
'🧪 ${appText('Worker/Review(Worker 池)', 'Worker/Review Pool')}',
|
||||
description: appText(
|
||||
'负责 glm/qwen worker lane、回归审阅和补充建议。',
|
||||
'Owns glm/qwen worker lanes, review, regression checks, and follow-up notes.',
|
||||
@ -1665,10 +1682,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
cliTool: config.tester.cliTool,
|
||||
model: config.tester.model,
|
||||
enabled: config.tester.enabled,
|
||||
cliOptions: _mergeOptions(
|
||||
config.tester.cliTool,
|
||||
const ['opencode', 'codex', 'claude', 'gemini'],
|
||||
),
|
||||
cliOptions: _mergeOptions(config.tester.cliTool, const [
|
||||
'opencode',
|
||||
'codex',
|
||||
'claude',
|
||||
'gemini',
|
||||
]),
|
||||
modelOptions: _getWorkerModelOptions(settings, config),
|
||||
onCliChanged: (tool) => _saveMultiAgentConfig(
|
||||
controller,
|
||||
@ -1868,7 +1887,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_WorkflowStep(
|
||||
label: '1',
|
||||
emoji: '🧭',
|
||||
title: appText('Architect(调度/文档)', 'Architect (Docs / Scheduler)'),
|
||||
title: appText(
|
||||
'Architect(调度/文档)',
|
||||
'Architect (Docs / Scheduler)',
|
||||
),
|
||||
desc: appText(
|
||||
'收敛 requirements -> acceptance evidence,并冻结里程碑。',
|
||||
'Freeze requirements -> acceptance evidence and milestones.',
|
||||
@ -1976,7 +1998,44 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
SettingsSnapshot settings,
|
||||
UiFeatureAccess uiFeatures,
|
||||
) {
|
||||
final toggles = <Widget>[
|
||||
if (uiFeatures.allowsExperimentalSetting(
|
||||
UiFeatureKeys.settingsExperimentalCanvas,
|
||||
))
|
||||
_SwitchRow(
|
||||
label: appText('Canvas 宿主', 'Canvas host'),
|
||||
value: settings.experimentalCanvas,
|
||||
onChanged: (value) => _saveSettings(
|
||||
controller,
|
||||
settings.copyWith(experimentalCanvas: value),
|
||||
),
|
||||
),
|
||||
if (uiFeatures.allowsExperimentalSetting(
|
||||
UiFeatureKeys.settingsExperimentalBridge,
|
||||
))
|
||||
_SwitchRow(
|
||||
label: appText('桥接模式', 'Bridge mode'),
|
||||
value: settings.experimentalBridge,
|
||||
onChanged: (value) => _saveSettings(
|
||||
controller,
|
||||
settings.copyWith(experimentalBridge: value),
|
||||
),
|
||||
),
|
||||
if (uiFeatures.allowsExperimentalSetting(
|
||||
UiFeatureKeys.settingsExperimentalDebug,
|
||||
))
|
||||
_SwitchRow(
|
||||
label: appText('调试运行时', 'Debug runtime'),
|
||||
value: settings.experimentalDebug,
|
||||
onChanged: (value) => _saveSettings(
|
||||
controller,
|
||||
settings.copyWith(experimentalDebug: value),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return [
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
@ -1987,30 +2046,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_SwitchRow(
|
||||
label: appText('Canvas 宿主', 'Canvas host'),
|
||||
value: settings.experimentalCanvas,
|
||||
onChanged: (value) => _saveSettings(
|
||||
controller,
|
||||
settings.copyWith(experimentalCanvas: value),
|
||||
if (toggles.isEmpty)
|
||||
Text(
|
||||
appText(
|
||||
'当前发布配置未开放额外实验开关。',
|
||||
'This build does not expose additional experimental toggles.',
|
||||
),
|
||||
),
|
||||
),
|
||||
_SwitchRow(
|
||||
label: appText('桥接模式', 'Bridge mode'),
|
||||
value: settings.experimentalBridge,
|
||||
onChanged: (value) => _saveSettings(
|
||||
controller,
|
||||
settings.copyWith(experimentalBridge: value),
|
||||
),
|
||||
),
|
||||
_SwitchRow(
|
||||
label: appText('调试运行时', 'Debug runtime'),
|
||||
value: settings.experimentalDebug,
|
||||
onChanged: (value) => _saveSettings(
|
||||
controller,
|
||||
settings.copyWith(experimentalDebug: value),
|
||||
),
|
||||
),
|
||||
...toggles,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
import 'app/ui_feature_manifest.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const XWorkmateApp());
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final featureManifest = await UiFeatureManifestLoader.load();
|
||||
runApp(XWorkmateApp(featureManifest: featureManifest));
|
||||
}
|
||||
|
||||
@ -233,6 +233,7 @@ class AppTheme {
|
||||
);
|
||||
|
||||
return base.copyWith(
|
||||
platform: resolvedPlatform,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: isDesktop
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_controller_web.dart';
|
||||
import '../app/ui_feature_manifest.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/runtime_models.dart';
|
||||
@ -39,6 +40,7 @@ class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
final uiFeatures = controller.featuresFor(UiFeaturePlatform.web);
|
||||
final allDirect = controller.conversationsForTarget(
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
);
|
||||
@ -48,6 +50,13 @@ class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
final direct = _filterConversations(allDirect);
|
||||
final relay = _filterConversations(allRelay);
|
||||
final currentTarget = controller.assistantExecutionTarget;
|
||||
final availableTargets = uiFeatures.availableExecutionTargets
|
||||
.where(
|
||||
(target) =>
|
||||
target == AssistantExecutionTarget.aiGatewayOnly ||
|
||||
target == AssistantExecutionTarget.remote,
|
||||
)
|
||||
.toList(growable: false);
|
||||
final connected =
|
||||
currentTarget == AssistantExecutionTarget.aiGatewayOnly
|
||||
? controller.canUseAiGatewayConversation
|
||||
@ -95,6 +104,7 @@ class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
label: Text(appText('连接设置', 'Connection settings')),
|
||||
),
|
||||
_TargetChip(
|
||||
targets: availableTargets,
|
||||
value: currentTarget,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
@ -118,6 +128,8 @@ class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
_searchController.clear();
|
||||
setState(() => _query = '');
|
||||
},
|
||||
showDirect: uiFeatures.supportsDirectAi,
|
||||
showRelay: uiFeatures.supportsRelayGateway,
|
||||
direct: direct,
|
||||
relay: relay,
|
||||
);
|
||||
@ -175,6 +187,8 @@ class _ConversationRail extends StatelessWidget {
|
||||
required this.searchController,
|
||||
required this.onQueryChanged,
|
||||
required this.onClearQuery,
|
||||
required this.showDirect,
|
||||
required this.showRelay,
|
||||
required this.direct,
|
||||
required this.relay,
|
||||
});
|
||||
@ -184,6 +198,8 @@ class _ConversationRail extends StatelessWidget {
|
||||
final TextEditingController searchController;
|
||||
final ValueChanged<String> onQueryChanged;
|
||||
final VoidCallback onClearQuery;
|
||||
final bool showDirect;
|
||||
final bool showRelay;
|
||||
final List<WebConversationSummary> direct;
|
||||
final List<WebConversationSummary> relay;
|
||||
|
||||
@ -214,30 +230,32 @@ class _ConversationRail extends StatelessWidget {
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
_ConversationGroup(
|
||||
title: appText('Direct AI Gateway', 'Direct AI Gateway'),
|
||||
icon: Icons.hub_rounded,
|
||||
items: direct,
|
||||
emptyLabel: appText(
|
||||
'还没有 Direct AI 对话',
|
||||
'No Direct AI conversations yet',
|
||||
if (showDirect)
|
||||
_ConversationGroup(
|
||||
title: appText('Direct AI Gateway', 'Direct AI Gateway'),
|
||||
icon: Icons.hub_rounded,
|
||||
items: direct,
|
||||
emptyLabel: appText(
|
||||
'还没有 Direct AI 对话',
|
||||
'No Direct AI conversations yet',
|
||||
),
|
||||
onSelect: controller.switchConversation,
|
||||
),
|
||||
onSelect: controller.switchConversation,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ConversationGroup(
|
||||
title: appText(
|
||||
'Relay OpenClaw Gateway',
|
||||
'Relay OpenClaw Gateway',
|
||||
if (showDirect && showRelay) const SizedBox(height: 12),
|
||||
if (showRelay)
|
||||
_ConversationGroup(
|
||||
title: appText(
|
||||
'Relay OpenClaw Gateway',
|
||||
'Relay OpenClaw Gateway',
|
||||
),
|
||||
icon: Icons.cloud_outlined,
|
||||
items: relay,
|
||||
emptyLabel: appText(
|
||||
'还没有 Relay 对话',
|
||||
'No Relay conversations yet',
|
||||
),
|
||||
onSelect: controller.switchConversation,
|
||||
),
|
||||
icon: Icons.cloud_outlined,
|
||||
items: relay,
|
||||
emptyLabel: appText(
|
||||
'还没有 Relay 对话',
|
||||
'No Relay conversations yet',
|
||||
),
|
||||
onSelect: controller.switchConversation,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -588,8 +606,13 @@ class _MessageBubble extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _TargetChip extends StatelessWidget {
|
||||
const _TargetChip({required this.value, required this.onChanged});
|
||||
const _TargetChip({
|
||||
required this.targets,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final List<AssistantExecutionTarget> targets;
|
||||
final AssistantExecutionTarget value;
|
||||
final ValueChanged<AssistantExecutionTarget?> onChanged;
|
||||
|
||||
@ -599,18 +622,14 @@ class _TargetChip extends StatelessWidget {
|
||||
child: DropdownButton<AssistantExecutionTarget>(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
items:
|
||||
const <AssistantExecutionTarget>[
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
AssistantExecutionTarget.remote,
|
||||
]
|
||||
.map((target) {
|
||||
return DropdownMenuItem<AssistantExecutionTarget>(
|
||||
value: target,
|
||||
child: Text(_targetLabel(target)),
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
items: targets
|
||||
.map((target) {
|
||||
return DropdownMenuItem<AssistantExecutionTarget>(
|
||||
value: target,
|
||||
child: Text(_targetLabel(target)),
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_controller_web.dart';
|
||||
import '../app/app_metadata.dart';
|
||||
import '../app/ui_feature_manifest.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/runtime_models.dart';
|
||||
@ -100,7 +101,11 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
final settings = controller.settings;
|
||||
final currentTab = controller.settingsTab;
|
||||
final uiFeatures = controller.featuresFor(UiFeaturePlatform.web);
|
||||
final availableTabs = uiFeatures.availableSettingsTabs;
|
||||
final currentTab = uiFeatures.sanitizeSettingsTab(
|
||||
controller.settingsTab,
|
||||
);
|
||||
return DesktopWorkspaceScaffold(
|
||||
breadcrumbs: <AppBreadcrumbItem>[
|
||||
AppBreadcrumbItem(
|
||||
@ -159,15 +164,10 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
child: Column(
|
||||
children: [
|
||||
SectionTabs(
|
||||
items: const <SettingsTab>[
|
||||
SettingsTab.general,
|
||||
SettingsTab.gateway,
|
||||
SettingsTab.appearance,
|
||||
SettingsTab.about,
|
||||
].map((item) => item.label).toList(),
|
||||
items: availableTabs.map((item) => item.label).toList(),
|
||||
value: currentTab.label,
|
||||
onChanged: (label) {
|
||||
final tab = SettingsTab.values.firstWhere(
|
||||
final tab = availableTabs.firstWhere(
|
||||
(item) => item.label == label,
|
||||
);
|
||||
controller.setSettingsTab(tab);
|
||||
@ -201,6 +201,15 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
}
|
||||
|
||||
List<Widget> _buildGeneral(BuildContext context, AppController controller) {
|
||||
final targets = controller
|
||||
.featuresFor(UiFeaturePlatform.web)
|
||||
.availableExecutionTargets
|
||||
.where(
|
||||
(target) =>
|
||||
target == AssistantExecutionTarget.aiGatewayOnly ||
|
||||
target == AssistantExecutionTarget.remote,
|
||||
)
|
||||
.toList(growable: false);
|
||||
return [
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
@ -213,18 +222,14 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
const SizedBox(height: 10),
|
||||
DropdownButtonFormField<AssistantExecutionTarget>(
|
||||
initialValue: controller.assistantExecutionTarget,
|
||||
items:
|
||||
const <AssistantExecutionTarget>[
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
AssistantExecutionTarget.remote,
|
||||
]
|
||||
.map((target) {
|
||||
return DropdownMenuItem<AssistantExecutionTarget>(
|
||||
value: target,
|
||||
child: Text(_targetLabel(target)),
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
items: targets
|
||||
.map((target) {
|
||||
return DropdownMenuItem<AssistantExecutionTarget>(
|
||||
value: target,
|
||||
child: Text(_targetLabel(target)),
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.setAssistantExecutionTarget(value);
|
||||
|
||||
44
lib/widgets/app_brand_logo.dart
Normal file
@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_metadata.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
|
||||
class AppBrandLogo extends StatelessWidget {
|
||||
const AppBrandLogo({
|
||||
super.key,
|
||||
this.size = 32,
|
||||
this.borderRadius = 10,
|
||||
this.showShadow = true,
|
||||
});
|
||||
|
||||
final double size;
|
||||
final double borderRadius;
|
||||
final bool showShadow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: Border.all(color: palette.chromeStroke),
|
||||
boxShadow: showShadow ? [palette.chromeShadowLift] : const [],
|
||||
),
|
||||
child: Image.asset(
|
||||
kProductLogoAsset,
|
||||
fit: BoxFit.contain,
|
||||
filterQuality: FilterQuality.medium,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
Icons.crop_square_rounded,
|
||||
color: palette.textSecondary,
|
||||
size: size * 0.64,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,7 @@ class _AssistantFocusPanelState extends State<AssistantFocusPanel> {
|
||||
final palette = context.palette;
|
||||
final favorites = widget.controller.assistantNavigationDestinations;
|
||||
final available = kAssistantNavigationDestinationCandidates
|
||||
.where(widget.controller.capabilities.supportsDestination)
|
||||
.where((item) => !favorites.contains(item))
|
||||
.toList(growable: false);
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_controller.dart';
|
||||
import '../app/ui_feature_manifest.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/runtime_models.dart';
|
||||
@ -85,6 +86,15 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
|
||||
_loadBootstrapPrefill();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final uiFeatures = widget.controller.featuresFor(
|
||||
resolveUiFeaturePlatformFromContext(context),
|
||||
);
|
||||
_connectionMode = _sanitizeConnectionMode(_connectionMode, uiFeatures);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_setupCodeController.dispose();
|
||||
@ -97,6 +107,10 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final uiFeatures = widget.controller.featuresFor(
|
||||
resolveUiFeaturePlatformFromContext(context),
|
||||
);
|
||||
final availableConnectionModes = _availableConnectionModes(uiFeatures);
|
||||
final theme = Theme.of(context);
|
||||
final palette = context.palette;
|
||||
final horizontalPadding = widget.compact ? 20.0 : 24.0;
|
||||
@ -198,7 +212,7 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('工作模式', 'Work Mode'),
|
||||
),
|
||||
items: RuntimeConnectionMode.values
|
||||
items: availableConnectionModes
|
||||
.map(
|
||||
(mode) => DropdownMenuItem<RuntimeConnectionMode>(
|
||||
value: mode,
|
||||
@ -456,6 +470,30 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<RuntimeConnectionMode> _availableConnectionModes(
|
||||
UiFeatureAccess uiFeatures,
|
||||
) {
|
||||
return <RuntimeConnectionMode>[
|
||||
if (uiFeatures.supportsDirectAi) RuntimeConnectionMode.unconfigured,
|
||||
if (uiFeatures.supportsLocalGateway) RuntimeConnectionMode.local,
|
||||
if (uiFeatures.supportsRelayGateway) RuntimeConnectionMode.remote,
|
||||
];
|
||||
}
|
||||
|
||||
RuntimeConnectionMode _sanitizeConnectionMode(
|
||||
RuntimeConnectionMode mode,
|
||||
UiFeatureAccess uiFeatures,
|
||||
) {
|
||||
final available = _availableConnectionModes(uiFeatures);
|
||||
if (available.contains(mode)) {
|
||||
return mode;
|
||||
}
|
||||
if (available.isNotEmpty) {
|
||||
return available.first;
|
||||
}
|
||||
return RuntimeConnectionMode.unconfigured;
|
||||
}
|
||||
}
|
||||
|
||||
class _SharedTokenStatusCard extends StatelessWidget {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_brand_logo.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
@ -24,6 +25,7 @@ class SidebarNavigation extends StatelessWidget {
|
||||
this.expandedWidthOverride,
|
||||
this.marginOverride,
|
||||
this.showCollapseControl = true,
|
||||
this.availableDestinations,
|
||||
this.favoriteDestinations = const <WorkspaceDestination>{},
|
||||
this.onToggleFavorite,
|
||||
});
|
||||
@ -44,6 +46,7 @@ class SidebarNavigation extends StatelessWidget {
|
||||
final double? expandedWidthOverride;
|
||||
final EdgeInsetsGeometry? marginOverride;
|
||||
final bool showCollapseControl;
|
||||
final Set<WorkspaceDestination>? availableDestinations;
|
||||
final Set<WorkspaceDestination> favoriteDestinations;
|
||||
final Future<void> Function(WorkspaceDestination section)? onToggleFavorite;
|
||||
|
||||
@ -70,6 +73,9 @@ class SidebarNavigation extends StatelessWidget {
|
||||
final palette = context.palette;
|
||||
final isExpanded = sidebarState == AppSidebarState.expanded;
|
||||
final isCollapsed = sidebarState == AppSidebarState.collapsed;
|
||||
final primarySections = _filterSections(_primarySections);
|
||||
final workspaceSections = _filterSections(_workspaceSections);
|
||||
final toolSections = _filterSections(_toolSections);
|
||||
final expandedWidth =
|
||||
expandedWidthOverride ??
|
||||
(appLanguage == AppLanguage.zh
|
||||
@ -115,41 +121,46 @@ class SidebarNavigation extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SidebarSectionGroup(
|
||||
sections: _primarySections,
|
||||
currentSection: currentSection,
|
||||
collapsed: isCollapsed,
|
||||
emphasis: _SidebarItemEmphasis.primary,
|
||||
favoriteDestinations: favoriteDestinations,
|
||||
onToggleFavorite: onToggleFavorite,
|
||||
onSectionChanged: onSectionChanged,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_SidebarSectionGroup(
|
||||
title: appText('工作区', 'Workspace'),
|
||||
sections: _workspaceSections,
|
||||
currentSection: currentSection,
|
||||
collapsed: isCollapsed,
|
||||
emphasis: _SidebarItemEmphasis.secondary,
|
||||
favoriteDestinations: favoriteDestinations,
|
||||
onToggleFavorite: onToggleFavorite,
|
||||
onSectionChanged: onSectionChanged,
|
||||
),
|
||||
if (primarySections.isNotEmpty)
|
||||
_SidebarSectionGroup(
|
||||
sections: primarySections,
|
||||
currentSection: currentSection,
|
||||
collapsed: isCollapsed,
|
||||
emphasis: _SidebarItemEmphasis.primary,
|
||||
favoriteDestinations: favoriteDestinations,
|
||||
onToggleFavorite: onToggleFavorite,
|
||||
onSectionChanged: onSectionChanged,
|
||||
),
|
||||
if (primarySections.isNotEmpty &&
|
||||
workspaceSections.isNotEmpty)
|
||||
const SizedBox(height: 6),
|
||||
if (workspaceSections.isNotEmpty)
|
||||
_SidebarSectionGroup(
|
||||
title: appText('工作区', 'Workspace'),
|
||||
sections: workspaceSections,
|
||||
currentSection: currentSection,
|
||||
collapsed: isCollapsed,
|
||||
emphasis: _SidebarItemEmphasis.secondary,
|
||||
favoriteDestinations: favoriteDestinations,
|
||||
onToggleFavorite: onToggleFavorite,
|
||||
onSectionChanged: onSectionChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_SidebarSectionGroup(
|
||||
title: appText('工具', 'Tools'),
|
||||
sections: _toolSections,
|
||||
currentSection: currentSection,
|
||||
collapsed: isCollapsed,
|
||||
emphasis: _SidebarItemEmphasis.secondary,
|
||||
favoriteDestinations: favoriteDestinations,
|
||||
onToggleFavorite: onToggleFavorite,
|
||||
onSectionChanged: onSectionChanged,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (toolSections.isNotEmpty)
|
||||
_SidebarSectionGroup(
|
||||
title: appText('工具', 'Tools'),
|
||||
sections: toolSections,
|
||||
currentSection: currentSection,
|
||||
collapsed: isCollapsed,
|
||||
emphasis: _SidebarItemEmphasis.secondary,
|
||||
favoriteDestinations: favoriteDestinations,
|
||||
onToggleFavorite: onToggleFavorite,
|
||||
onSectionChanged: onSectionChanged,
|
||||
),
|
||||
if (toolSections.isNotEmpty) const SizedBox(height: 6),
|
||||
SidebarFooter(
|
||||
isCollapsed: isCollapsed,
|
||||
currentSection: currentSection,
|
||||
@ -159,9 +170,19 @@ class SidebarNavigation extends StatelessWidget {
|
||||
onOpenThemeToggle: onOpenThemeToggle,
|
||||
onOpenSettings: () =>
|
||||
onSectionChanged(WorkspaceDestination.settings),
|
||||
showSettingsButton:
|
||||
availableDestinations == null ||
|
||||
availableDestinations!.contains(
|
||||
WorkspaceDestination.settings,
|
||||
),
|
||||
sidebarState: sidebarState,
|
||||
onCycleSidebarState: onCycleSidebarState,
|
||||
onOpenAccount: onOpenAccount,
|
||||
showAccountButton:
|
||||
availableDestinations == null ||
|
||||
availableDestinations!.contains(
|
||||
WorkspaceDestination.account,
|
||||
),
|
||||
accountName: accountName,
|
||||
accountSubtitle: accountSubtitle,
|
||||
accountSelected:
|
||||
@ -177,6 +198,16 @@ class SidebarNavigation extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<WorkspaceDestination> _filterSections(
|
||||
List<WorkspaceDestination> sections,
|
||||
) {
|
||||
final allowed = availableDestinations;
|
||||
if (allowed == null) {
|
||||
return sections;
|
||||
}
|
||||
return sections.where(allowed.contains).toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
class SidebarHeader extends StatelessWidget {
|
||||
@ -187,29 +218,9 @@ class SidebarHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
|
||||
final content = Container(
|
||||
width: isCollapsed ? 36 : 28,
|
||||
height: isCollapsed ? 36 : 28,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
palette.chromeHighlight.withValues(alpha: 0.88),
|
||||
palette.chromeSurfacePressed.withValues(alpha: 0.92),
|
||||
],
|
||||
),
|
||||
border: Border.all(color: palette.chromeStroke),
|
||||
boxShadow: [palette.chromeShadowLift],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.crop_square_rounded,
|
||||
color: palette.textSecondary,
|
||||
size: AppSizes.sidebarIconSize,
|
||||
),
|
||||
final content = AppBrandLogo(
|
||||
size: isCollapsed ? 36 : 28,
|
||||
borderRadius: isCollapsed ? 10 : 8,
|
||||
);
|
||||
|
||||
if (onTap == null) {
|
||||
@ -507,9 +518,11 @@ class SidebarFooter extends StatelessWidget {
|
||||
required this.onToggleLanguage,
|
||||
required this.onOpenThemeToggle,
|
||||
required this.onOpenSettings,
|
||||
required this.showSettingsButton,
|
||||
required this.sidebarState,
|
||||
required this.onCycleSidebarState,
|
||||
required this.onOpenAccount,
|
||||
required this.showAccountButton,
|
||||
required this.accountName,
|
||||
required this.accountSubtitle,
|
||||
required this.accountSelected,
|
||||
@ -524,9 +537,11 @@ class SidebarFooter extends StatelessWidget {
|
||||
final VoidCallback onToggleLanguage;
|
||||
final VoidCallback onOpenThemeToggle;
|
||||
final VoidCallback onOpenSettings;
|
||||
final bool showSettingsButton;
|
||||
final AppSidebarState sidebarState;
|
||||
final VoidCallback onCycleSidebarState;
|
||||
final VoidCallback onOpenAccount;
|
||||
final bool showAccountButton;
|
||||
final String accountName;
|
||||
final String accountSubtitle;
|
||||
final bool accountSelected;
|
||||
@ -574,12 +589,14 @@ class SidebarFooter extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
_SidebarActionButton(
|
||||
icon: Icons.tune_rounded,
|
||||
tooltip: appText('设置', 'Settings'),
|
||||
onPressed: onOpenSettings,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (showSettingsButton) ...[
|
||||
_SidebarActionButton(
|
||||
icon: Icons.tune_rounded,
|
||||
tooltip: appText('设置', 'Settings'),
|
||||
onPressed: onOpenSettings,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
if (onOpenOnlineWorkspace != null) ...[
|
||||
_SidebarActionButton(
|
||||
icon: Icons.open_in_new_rounded,
|
||||
@ -588,14 +605,15 @@ class SidebarFooter extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
_SidebarAccountTile(
|
||||
selected: accountSelected,
|
||||
onTap: onOpenAccount,
|
||||
name: accountName,
|
||||
subtitle: accountSubtitle,
|
||||
onlineActionLabel: appText('在线版', 'Online'),
|
||||
onOpenOnlineWorkspace: onOpenOnlineWorkspace,
|
||||
),
|
||||
if (showAccountButton)
|
||||
_SidebarAccountTile(
|
||||
selected: accountSelected,
|
||||
onTap: onOpenAccount,
|
||||
name: accountName,
|
||||
subtitle: accountSubtitle,
|
||||
onlineActionLabel: appText('在线版', 'Online'),
|
||||
onOpenOnlineWorkspace: onOpenOnlineWorkspace,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -609,16 +627,18 @@ class SidebarFooter extends StatelessWidget {
|
||||
color: palette.chromeStroke.withValues(alpha: 0.9),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
_SidebarNavItem(
|
||||
section: WorkspaceDestination.settings,
|
||||
selected: currentSection == WorkspaceDestination.settings,
|
||||
collapsed: false,
|
||||
emphasis: _SidebarItemEmphasis.secondary,
|
||||
favorite: false,
|
||||
showFavoriteToggle: false,
|
||||
onTap: onOpenSettings,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
if (showSettingsButton) ...[
|
||||
_SidebarNavItem(
|
||||
section: WorkspaceDestination.settings,
|
||||
selected: currentSection == WorkspaceDestination.settings,
|
||||
collapsed: false,
|
||||
emphasis: _SidebarItemEmphasis.secondary,
|
||||
favorite: false,
|
||||
showFavoriteToggle: false,
|
||||
onTap: onOpenSettings,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@ -649,14 +669,15 @@ class SidebarFooter extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
_SidebarAccountTile(
|
||||
selected: accountSelected,
|
||||
onTap: onOpenAccount,
|
||||
name: accountName,
|
||||
subtitle: accountSubtitle,
|
||||
onlineActionLabel: appText('在线版', 'Online'),
|
||||
onOpenOnlineWorkspace: onOpenOnlineWorkspace,
|
||||
),
|
||||
if (showAccountButton)
|
||||
_SidebarAccountTile(
|
||||
selected: accountSelected,
|
||||
onTap: onOpenAccount,
|
||||
name: accountName,
|
||||
subtitle: accountSubtitle,
|
||||
onlineActionLabel: appText('在线版', 'Online'),
|
||||
onOpenOnlineWorkspace: onOpenOnlineWorkspace,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 520 B After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 7.1 KiB |
@ -46,6 +46,8 @@ dev_dependencies:
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- config/feature_flags.yaml
|
||||
- assets/branding/
|
||||
- assets/aris/manifest.json
|
||||
- assets/aris/skills/
|
||||
- assets/aris/mcp-servers/
|
||||
|
||||
97
test/app/ui_feature_manifest_test.dart
Normal file
@ -0,0 +1,97 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_capabilities.dart';
|
||||
import 'package:xworkmate/app/ui_feature_manifest.dart';
|
||||
import 'package:xworkmate/models/app_models.dart';
|
||||
|
||||
void main() {
|
||||
test('fallback manifest applies release policy to feature availability', () {
|
||||
final manifest = UiFeatureManifest.fallback();
|
||||
final debugDesktop = manifest.forPlatform(
|
||||
UiFeaturePlatform.desktop,
|
||||
buildMode: UiFeatureBuildMode.debug,
|
||||
);
|
||||
final releaseDesktop = manifest.forPlatform(
|
||||
UiFeaturePlatform.desktop,
|
||||
buildMode: UiFeatureBuildMode.release,
|
||||
);
|
||||
|
||||
expect(
|
||||
debugDesktop.isEnabledPath(UiFeatureKeys.settingsExperimental),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
releaseDesktop.isEnabledPath(UiFeatureKeys.settingsExperimental),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
releaseDesktop.allowedDestinations.contains(WorkspaceDestination.tasks),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('capabilities are derived from feature access', () {
|
||||
final manifest = UiFeatureManifest.fallback();
|
||||
final webAccess = manifest.forPlatform(
|
||||
UiFeaturePlatform.web,
|
||||
buildMode: UiFeatureBuildMode.release,
|
||||
);
|
||||
final capabilities = AppCapabilities.fromFeatureAccess(webAccess);
|
||||
|
||||
expect(
|
||||
capabilities.allowedDestinations,
|
||||
equals(<WorkspaceDestination>{
|
||||
WorkspaceDestination.assistant,
|
||||
WorkspaceDestination.settings,
|
||||
}),
|
||||
);
|
||||
expect(capabilities.supportsFileAttachments, isFalse);
|
||||
expect(capabilities.supportsLocalGateway, isFalse);
|
||||
expect(capabilities.supportsRelayGateway, isTrue);
|
||||
expect(capabilities.supportsDesktopRuntime, isFalse);
|
||||
expect(capabilities.supportsDiagnostics, isFalse);
|
||||
});
|
||||
|
||||
test('parser rejects unsupported flag fields', () {
|
||||
expect(
|
||||
() => UiFeatureManifest.fromYamlString('''
|
||||
release_policy:
|
||||
debug: [stable]
|
||||
profile: [stable]
|
||||
release: [stable]
|
||||
desktop:
|
||||
navigation:
|
||||
assistant:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug]
|
||||
description: Assistant
|
||||
ui_surface: sidebar
|
||||
unsupported: bad
|
||||
mobile: {}
|
||||
web: {}
|
||||
'''),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('parser rejects missing required fields', () {
|
||||
expect(
|
||||
() => UiFeatureManifest.fromYamlString('''
|
||||
release_policy:
|
||||
debug: [stable]
|
||||
profile: [stable]
|
||||
release: [stable]
|
||||
desktop:
|
||||
navigation:
|
||||
assistant:
|
||||
enabled: true
|
||||
build_modes: [debug]
|
||||
description: Assistant
|
||||
ui_surface: sidebar
|
||||
mobile: {}
|
||||
web: {}
|
||||
'''),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -174,7 +174,7 @@ void main() {
|
||||
}
|
||||
|
||||
Future<void> _waitFor(bool Function() predicate) async {
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 10));
|
||||
while (!predicate()) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('condition not met before timeout');
|
||||
|
||||
@ -6,6 +6,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/app/ui_feature_manifest.dart';
|
||||
import 'package:xworkmate/features/assistant/assistant_page.dart';
|
||||
import 'package:xworkmate/runtime/codex_runtime.dart';
|
||||
import 'package:xworkmate/runtime/device_identity_store.dart';
|
||||
@ -53,6 +54,7 @@ void main() {
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
expect(
|
||||
@ -115,6 +117,7 @@ void main() {
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
expect(find.text('当前 0'), findsOneWidget);
|
||||
@ -128,6 +131,7 @@ void main() {
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
await tester.longPress(
|
||||
@ -405,6 +409,43 @@ void main() {
|
||||
expect(find.text('网页处理'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('AssistantPage hides gated attachment and multi-agent actions', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final manifest = UiFeatureManifest.fallback()
|
||||
.copyWithFeature(
|
||||
platform: UiFeaturePlatform.desktop,
|
||||
module: 'assistant',
|
||||
feature: 'file_attachments',
|
||||
enabled: false,
|
||||
)
|
||||
.copyWithFeature(
|
||||
platform: UiFeaturePlatform.desktop,
|
||||
module: 'assistant',
|
||||
feature: 'multi_agent',
|
||||
enabled: false,
|
||||
);
|
||||
final controller = await createTestController(
|
||||
tester,
|
||||
uiFeatureManifest: manifest,
|
||||
);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-attachment-menu-button')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-collaboration-toggle')),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('AssistantPage composer input area can be resized vertically', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
|
||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_shell.dart';
|
||||
import 'package:xworkmate/app/ui_feature_manifest.dart';
|
||||
import 'package:xworkmate/features/mobile/mobile_shell.dart';
|
||||
import 'package:xworkmate/models/app_models.dart';
|
||||
import 'package:xworkmate/widgets/detail_drawer.dart';
|
||||
@ -106,6 +107,33 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('MobileShell workspace launcher filters disabled entries', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final manifest = UiFeatureManifest.fallback().copyWithFeature(
|
||||
platform: UiFeaturePlatform.mobile,
|
||||
module: 'workspace',
|
||||
feature: 'mcp_server',
|
||||
enabled: false,
|
||||
);
|
||||
final controller = await createTestController(
|
||||
tester,
|
||||
uiFeatureManifest: manifest,
|
||||
);
|
||||
|
||||
await pumpMobileShell(
|
||||
tester,
|
||||
child: MobileShell(controller: controller),
|
||||
platform: TargetPlatform.android,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('工作区'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('MCP Hub'), findsNothing);
|
||||
expect(find.text('节点'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('MobileShell renders detail panels as bottom sheets', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
@ -190,7 +218,10 @@ void main() {
|
||||
);
|
||||
|
||||
expect(find.byKey(const ValueKey('mobile-safe-strip')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('mobile-safe-open-button')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('mobile-safe-open-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('mobile-safe-connect-button')),
|
||||
findsOneWidget,
|
||||
|
||||
@ -116,7 +116,7 @@ void main() {
|
||||
}
|
||||
|
||||
Future<void> _waitFor(bool Function() predicate) async {
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 10));
|
||||
while (!predicate()) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('condition not met before timeout');
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/ui_feature_manifest.dart';
|
||||
import 'package:xworkmate/features/settings/settings_page.dart';
|
||||
import 'package:xworkmate/models/app_models.dart';
|
||||
import 'package:xworkmate/runtime/desktop_platform_service.dart';
|
||||
@ -69,7 +70,11 @@ void main() {
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
|
||||
await pumpPage(tester, child: SettingsPage(controller: controller));
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: SettingsPage(controller: controller),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('外观'));
|
||||
await tester.pumpAndSettle();
|
||||
@ -88,7 +93,11 @@ void main() {
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
|
||||
await pumpPage(tester, child: SettingsPage(controller: controller));
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: SettingsPage(controller: controller),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('集成'));
|
||||
await tester.pumpAndSettle();
|
||||
@ -108,7 +117,11 @@ void main() {
|
||||
desktopPlatformService: _DesktopServiceStub(),
|
||||
);
|
||||
|
||||
await pumpPage(tester, child: SettingsPage(controller: controller));
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: SettingsPage(controller: controller),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('linux-desktop-integration-card')),
|
||||
@ -189,6 +202,37 @@ void main() {
|
||||
expect(controller.runtimeLogs, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage hides tabs disabled by feature manifest', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final manifest = UiFeatureManifest.fallback()
|
||||
.copyWithFeature(
|
||||
platform: UiFeaturePlatform.desktop,
|
||||
module: 'settings',
|
||||
feature: 'diagnostics',
|
||||
enabled: false,
|
||||
)
|
||||
.copyWithFeature(
|
||||
platform: UiFeaturePlatform.desktop,
|
||||
module: 'settings',
|
||||
feature: 'experimental',
|
||||
enabled: false,
|
||||
);
|
||||
final controller = await createTestController(
|
||||
tester,
|
||||
uiFeatureManifest: manifest,
|
||||
);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: SettingsPage(controller: controller),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
expect(find.text('诊断'), findsNothing);
|
||||
expect(find.text('实验特性'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage detail mode returns to overview', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
|
||||
@ -72,7 +72,7 @@ void main() {
|
||||
|
||||
Future<void> _waitFor(
|
||||
bool Function() condition, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (!condition()) {
|
||||
|
||||
@ -5,6 +5,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/app/ui_feature_manifest.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
import 'package:xworkmate/runtime/desktop_platform_service.dart';
|
||||
@ -12,6 +13,7 @@ import 'package:xworkmate/runtime/desktop_platform_service.dart';
|
||||
Future<AppController> createTestController(
|
||||
WidgetTester tester, {
|
||||
DesktopPlatformService? desktopPlatformService,
|
||||
UiFeatureManifest? uiFeatureManifest,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final controller = AppController(
|
||||
@ -21,6 +23,7 @@ Future<AppController> createTestController(
|
||||
'${Directory.systemTemp.path}/xworkmate-widget-tests',
|
||||
),
|
||||
desktopPlatformService: desktopPlatformService,
|
||||
uiFeatureManifest: uiFeatureManifest,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
791
tool/render_release_docs.dart
Normal file
@ -0,0 +1,791 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
const _platformOrder = <String>['mobile', 'desktop', 'web'];
|
||||
const _tierOrder = <String>['stable', 'beta', 'experimental'];
|
||||
const _buildModeOrder = <String>['debug', 'profile', 'release'];
|
||||
|
||||
void main() {
|
||||
final manifest = FeatureManifest.load();
|
||||
final git = GitSnapshot.capture();
|
||||
|
||||
_writeDoc(
|
||||
'docs/planning/xworkmate-ui-feature-matrix.md',
|
||||
_renderFeatureMatrix(manifest, git),
|
||||
);
|
||||
_writeDoc(
|
||||
'docs/planning/xworkmate-ui-feature-roadmap.md',
|
||||
_renderFeatureRoadmap(manifest, git),
|
||||
);
|
||||
_writeDoc(
|
||||
'docs/releases/xworkmate-release-notes.md',
|
||||
_renderReleaseNotes(manifest, git),
|
||||
);
|
||||
_writeDoc('docs/releases/xworkmate-changelog.md', _renderChangelog(git));
|
||||
|
||||
stdout.writeln(
|
||||
'Rendered docs/planning/xworkmate-ui-feature-matrix.md, '
|
||||
'docs/planning/xworkmate-ui-feature-roadmap.md, '
|
||||
'docs/releases/xworkmate-release-notes.md, '
|
||||
'and docs/releases/xworkmate-changelog.md',
|
||||
);
|
||||
}
|
||||
|
||||
void _writeDoc(String relativePath, String contents) {
|
||||
final file = File(relativePath);
|
||||
file.parent.createSync(recursive: true);
|
||||
file.writeAsStringSync(contents);
|
||||
}
|
||||
|
||||
String _renderFeatureMatrix(FeatureManifest manifest, GitSnapshot git) {
|
||||
final buffer = StringBuffer()
|
||||
..writeln('# XWorkmate UI Feature Matrix')
|
||||
..writeln()
|
||||
..writeln(_generatedPreamble(git))
|
||||
..writeln()
|
||||
..writeln('## Release Policy')
|
||||
..writeln()
|
||||
..writeln('| Build Mode | 可见 Tier | 说明 |')
|
||||
..writeln('| --- | --- | --- |');
|
||||
|
||||
for (final buildMode in _buildModeOrder) {
|
||||
final tiers = manifest.releasePolicy[buildMode] ?? const <String>[];
|
||||
final note = switch (buildMode) {
|
||||
'debug' => '内部开发与功能联调',
|
||||
'profile' => '预发布验收与性能验证',
|
||||
'release' => '面向用户交付的正式版本',
|
||||
_ => '-',
|
||||
};
|
||||
buffer.writeln(
|
||||
'| `${_escapeMarkdown(buildMode)}` | `${tiers.join(', ')}` | $note |',
|
||||
);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln(
|
||||
'`release_policy` 是全局上限;单个 flag 还必须同时满足 '
|
||||
'`enabled: true` 和自身 `build_modes` 才会真正出现在 UI 中。',
|
||||
)
|
||||
..writeln()
|
||||
..writeln('## Snapshot Summary')
|
||||
..writeln()
|
||||
..writeln(
|
||||
'| 平台 | Flag 总数 | 已启用 | Stable | Beta | Experimental | Disabled |',
|
||||
)
|
||||
..writeln('| --- | --- | --- | --- | --- | --- | --- |');
|
||||
|
||||
var total = 0;
|
||||
var totalEnabled = 0;
|
||||
var totalDisabled = 0;
|
||||
final tierTotals = <String, int>{for (final tier in _tierOrder) tier: 0};
|
||||
|
||||
for (final platform in _platformOrder) {
|
||||
final records = manifest.recordsFor(platform);
|
||||
final enabled = records.where((record) => record.enabled).length;
|
||||
final disabled = records.length - enabled;
|
||||
total += records.length;
|
||||
totalEnabled += enabled;
|
||||
totalDisabled += disabled;
|
||||
|
||||
final perTier = <String, int>{for (final tier in _tierOrder) tier: 0};
|
||||
for (final record in records.where((record) => record.enabled)) {
|
||||
perTier[record.releaseTier] = (perTier[record.releaseTier] ?? 0) + 1;
|
||||
tierTotals[record.releaseTier] =
|
||||
(tierTotals[record.releaseTier] ?? 0) + 1;
|
||||
}
|
||||
|
||||
buffer.writeln(
|
||||
'| `${_escapeMarkdown(platform)}` | ${records.length} | $enabled | '
|
||||
'${perTier['stable']} | ${perTier['beta']} | '
|
||||
'${perTier['experimental']} | $disabled |',
|
||||
);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln(
|
||||
'| `total` | $total | $totalEnabled | ${tierTotals['stable']} | '
|
||||
'${tierTotals['beta']} | ${tierTotals['experimental']} | $totalDisabled |',
|
||||
)
|
||||
..writeln();
|
||||
|
||||
for (final platform in _platformOrder) {
|
||||
buffer
|
||||
..writeln('## ${_titleCase(platform)}')
|
||||
..writeln()
|
||||
..writeln('| 模块 | Flag | 状态 | Tier | Build Modes | UI Surface | 说明 |')
|
||||
..writeln('| --- | --- | --- | --- | --- | --- | --- |');
|
||||
|
||||
for (final record in manifest.recordsFor(platform)) {
|
||||
final modes = record.buildModes.isEmpty
|
||||
? '-'
|
||||
: _escapeMarkdown(record.buildModes.join(', '));
|
||||
final state = record.enabled ? 'enabled' : 'disabled';
|
||||
buffer.writeln(
|
||||
'| `${_escapeMarkdown(record.module)}` | '
|
||||
'`${_escapeMarkdown(record.name)}` | $state | '
|
||||
'`${_escapeMarkdown(record.releaseTier)}` | '
|
||||
'`$modes` | '
|
||||
'`${_escapeMarkdown(record.uiSurface)}` | '
|
||||
'${_escapeMarkdown(record.description)} |',
|
||||
);
|
||||
}
|
||||
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _renderFeatureRoadmap(FeatureManifest manifest, GitSnapshot git) {
|
||||
final buffer = StringBuffer()
|
||||
..writeln('# XWorkmate UI Feature Flag Roadmap')
|
||||
..writeln()
|
||||
..writeln(_generatedPreamble(git))
|
||||
..writeln()
|
||||
..writeln('## 规划规则')
|
||||
..writeln()
|
||||
..writeln(
|
||||
'- `release_policy` 决定 build mode 的总开关上限:`debug` 可见 '
|
||||
'`stable / beta / experimental`,`profile` 可见 `stable / beta`,'
|
||||
'`release` 仅可见 `stable`。',
|
||||
)
|
||||
..writeln('- 单个 flag 的交付状态由三层共同决定:`enabled`、`release_tier`、`build_modes`。')
|
||||
..writeln(
|
||||
'- `enabled: false` 或 `build_modes: []` 的项,会在文档里继续保留,'
|
||||
'但不会进入当前 build mode 的用户可见范围。',
|
||||
)
|
||||
..writeln()
|
||||
..writeln('## Build Visibility Summary')
|
||||
..writeln()
|
||||
..writeln(
|
||||
'| 平台 | Debug Visible | Profile Visible | Release Visible | Suppressed |',
|
||||
)
|
||||
..writeln('| --- | --- | --- | --- | --- |');
|
||||
|
||||
for (final platform in _platformOrder) {
|
||||
final debugVisible = manifest.visibleFlags(platform, 'debug').length;
|
||||
final profileVisible = manifest.visibleFlags(platform, 'profile').length;
|
||||
final releaseVisible = manifest.visibleFlags(platform, 'release').length;
|
||||
final suppressed = manifest
|
||||
.recordsFor(platform)
|
||||
.where((record) => !record.visibleIn('debug', manifest.releasePolicy))
|
||||
.length;
|
||||
buffer.writeln(
|
||||
'| `${_escapeMarkdown(platform)}` | $debugVisible | $profileVisible | '
|
||||
'$releaseVisible | $suppressed |',
|
||||
);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('## Release Baseline')
|
||||
..writeln()
|
||||
..writeln('| 平台 | 数量 | Flag 列表 |')
|
||||
..writeln('| --- | --- | --- |');
|
||||
|
||||
for (final platform in _platformOrder) {
|
||||
final releaseFlags = manifest.visibleFlags(platform, 'release');
|
||||
buffer.writeln(
|
||||
'| `${_escapeMarkdown(platform)}` | ${releaseFlags.length} | '
|
||||
'${_flagList(releaseFlags)} |',
|
||||
);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('## Profile-only Lane')
|
||||
..writeln()
|
||||
..writeln('| 平台 | 数量 | 相比 Release 新增 |')
|
||||
..writeln('| --- | --- | --- |');
|
||||
|
||||
for (final platform in _platformOrder) {
|
||||
final profileOnly = _difference(
|
||||
manifest.visibleFlags(platform, 'profile'),
|
||||
manifest.visibleFlags(platform, 'release'),
|
||||
);
|
||||
buffer.writeln(
|
||||
'| `${_escapeMarkdown(platform)}` | ${profileOnly.length} | '
|
||||
'${_flagList(profileOnly)} |',
|
||||
);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('## Debug-only Experimental Lane')
|
||||
..writeln()
|
||||
..writeln('| 平台 | 数量 | 相比 Profile 新增 |')
|
||||
..writeln('| --- | --- | --- |');
|
||||
|
||||
for (final platform in _platformOrder) {
|
||||
final debugOnly = _difference(
|
||||
manifest.visibleFlags(platform, 'debug'),
|
||||
manifest.visibleFlags(platform, 'profile'),
|
||||
);
|
||||
buffer.writeln(
|
||||
'| `${_escapeMarkdown(platform)}` | ${debugOnly.length} | '
|
||||
'${_flagList(debugOnly)} |',
|
||||
);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('## Explicitly Suppressed')
|
||||
..writeln()
|
||||
..writeln('| 平台 | 数量 | Flag 列表 |')
|
||||
..writeln('| --- | --- | --- |');
|
||||
|
||||
for (final platform in _platformOrder) {
|
||||
final suppressed = manifest
|
||||
.recordsFor(platform)
|
||||
.where((record) => !record.visibleIn('debug', manifest.releasePolicy))
|
||||
.toList(growable: false);
|
||||
buffer.writeln(
|
||||
'| `${_escapeMarkdown(platform)}` | ${suppressed.length} | '
|
||||
'${_flagList(suppressed)} |',
|
||||
);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('## Tier Inventory')
|
||||
..writeln();
|
||||
|
||||
for (final platform in _platformOrder) {
|
||||
buffer.writeln('### ${_titleCase(platform)}');
|
||||
buffer.writeln();
|
||||
for (final tier in [..._tierOrder, 'disabled']) {
|
||||
final records = manifest
|
||||
.recordsFor(platform)
|
||||
.where((record) {
|
||||
if (!record.enabled) {
|
||||
return tier == 'disabled';
|
||||
}
|
||||
return record.releaseTier == tier;
|
||||
})
|
||||
.toList(growable: false);
|
||||
if (records.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
buffer.writeln('- `$tier`: ${_flagList(records)}');
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _renderReleaseNotes(FeatureManifest manifest, GitSnapshot git) {
|
||||
final profileOnlyAll = <FeatureFlagRecord>[];
|
||||
final debugOnlyAll = <FeatureFlagRecord>[];
|
||||
|
||||
for (final platform in _platformOrder) {
|
||||
profileOnlyAll.addAll(
|
||||
_difference(
|
||||
manifest.visibleFlags(platform, 'profile'),
|
||||
manifest.visibleFlags(platform, 'release'),
|
||||
),
|
||||
);
|
||||
debugOnlyAll.addAll(
|
||||
_difference(
|
||||
manifest.visibleFlags(platform, 'debug'),
|
||||
manifest.visibleFlags(platform, 'profile'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final categorized = _categorizeCommits(git.commits);
|
||||
final buffer = StringBuffer()
|
||||
..writeln('# XWorkmate Release Notes')
|
||||
..writeln()
|
||||
..writeln(_generatedPreamble(git))
|
||||
..writeln()
|
||||
..writeln('## Git Snapshot')
|
||||
..writeln()
|
||||
..writeln('| 字段 | 值 |')
|
||||
..writeln('| --- | --- |')
|
||||
..writeln('| Branch | `${_escapeMarkdown(git.branch)}` |')
|
||||
..writeln('| Head Commit | `${_escapeMarkdown(git.headShort)}` |')
|
||||
..writeln('| Head Tags | ${_inlineValue(git.headTags.join(', '))} |')
|
||||
..writeln('| Latest Tag | ${_inlineValue(git.latestTag ?? '-')} |')
|
||||
..writeln('| Previous Tag | ${_inlineValue(git.previousTag ?? '-')} |')
|
||||
..writeln(
|
||||
'| Comparison Range | `${_escapeMarkdown(git.comparisonRangeLabel)}` |',
|
||||
)
|
||||
..writeln('| Commit Count | ${git.commits.length} |')
|
||||
..writeln()
|
||||
..writeln('## Feature Snapshot')
|
||||
..writeln()
|
||||
..writeln('| 平台 | Debug | Profile | Release | Suppressed |')
|
||||
..writeln('| --- | --- | --- | --- | --- |');
|
||||
|
||||
for (final platform in _platformOrder) {
|
||||
buffer.writeln(
|
||||
'| `${_escapeMarkdown(platform)}` | '
|
||||
'${manifest.visibleFlags(platform, 'debug').length} | '
|
||||
'${manifest.visibleFlags(platform, 'profile').length} | '
|
||||
'${manifest.visibleFlags(platform, 'release').length} | '
|
||||
'${manifest.recordsFor(platform).where((record) => !record.visibleIn('debug', manifest.releasePolicy)).length} |',
|
||||
);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('## Current Focus')
|
||||
..writeln()
|
||||
..writeln(
|
||||
'- `release` 当前面向用户暴露 ${manifest.visibleFlagCount('release')} 个 UI feature flags,'
|
||||
'全部来自 `stable` tier。',
|
||||
)
|
||||
..writeln(
|
||||
'- `profile` 相比 `release` 额外开放 ${profileOnlyAll.length} 个预发布条目:'
|
||||
' ${_flagList(profileOnlyAll, includePlatform: true)}。',
|
||||
)
|
||||
..writeln(
|
||||
'- `debug` 相比 `profile` 额外开放 ${debugOnlyAll.length} 个实验条目:'
|
||||
' ${_flagList(debugOnlyAll, includePlatform: true)}。',
|
||||
)
|
||||
..writeln()
|
||||
..writeln('## Commit Highlights')
|
||||
..writeln();
|
||||
|
||||
if (git.commits.isEmpty) {
|
||||
buffer.writeln('当前比较范围没有可渲染的 commits。');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
for (final entry in categorized.entries) {
|
||||
if (entry.value.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
buffer.writeln('### ${entry.key}');
|
||||
buffer.writeln();
|
||||
for (final commit in entry.value) {
|
||||
buffer.writeln(
|
||||
'- `${_escapeMarkdown(commit.hash)}` ${_escapeMarkdown(commit.subject)}',
|
||||
);
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _renderChangelog(GitSnapshot git) {
|
||||
final buffer = StringBuffer()
|
||||
..writeln('# XWorkmate Changelog')
|
||||
..writeln()
|
||||
..writeln(_generatedPreamble(git))
|
||||
..writeln()
|
||||
..writeln('## Git Snapshot')
|
||||
..writeln()
|
||||
..writeln('| 字段 | 值 |')
|
||||
..writeln('| --- | --- |')
|
||||
..writeln('| Branch | `${_escapeMarkdown(git.branch)}` |')
|
||||
..writeln('| Head Commit | `${_escapeMarkdown(git.headShort)}` |')
|
||||
..writeln('| Head Tags | ${_inlineValue(git.headTags.join(', '))} |')
|
||||
..writeln('| Latest Tag | ${_inlineValue(git.latestTag ?? '-')} |')
|
||||
..writeln('| Previous Tag | ${_inlineValue(git.previousTag ?? '-')} |')
|
||||
..writeln(
|
||||
'| Comparison Range | `${_escapeMarkdown(git.comparisonRangeLabel)}` |',
|
||||
)
|
||||
..writeln()
|
||||
..writeln('## Recent Tags')
|
||||
..writeln()
|
||||
..writeln('| Tag | Date |')
|
||||
..writeln('| --- | --- |');
|
||||
|
||||
for (final tag in git.recentTags) {
|
||||
buffer.writeln(
|
||||
'| `${_escapeMarkdown(tag.name)}` | `${_escapeMarkdown(tag.date)}` |',
|
||||
);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('## Commits')
|
||||
..writeln()
|
||||
..writeln('| Hash | Date | Author | Subject |')
|
||||
..writeln('| --- | --- | --- | --- |');
|
||||
|
||||
if (git.commits.isEmpty) {
|
||||
buffer.writeln(
|
||||
'| `-` | `-` | `-` | No commits found for the selected range |',
|
||||
);
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
for (final commit in git.commits) {
|
||||
buffer.writeln(
|
||||
'| `${_escapeMarkdown(commit.hash)}` | '
|
||||
'`${_escapeMarkdown(commit.date)}` | '
|
||||
'${_escapeMarkdown(commit.author)} | '
|
||||
'${_escapeMarkdown(commit.subject)} |',
|
||||
);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _generatedPreamble(GitSnapshot git) {
|
||||
return [
|
||||
'> Generated by `tool/render_release_docs.dart`',
|
||||
'> Source manifest: [`config/feature_flags.yaml`](../../config/feature_flags.yaml)',
|
||||
'> Generated at: `${_escapeMarkdown(git.generatedAt)}`',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
String _flagList(
|
||||
List<FeatureFlagRecord> records, {
|
||||
bool includePlatform = false,
|
||||
}) {
|
||||
if (records.isEmpty) {
|
||||
return '-';
|
||||
}
|
||||
return records
|
||||
.map(
|
||||
(record) =>
|
||||
'`${_escapeMarkdown(includePlatform ? record.qualifiedId : record.id)}`',
|
||||
)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
List<FeatureFlagRecord> _difference(
|
||||
List<FeatureFlagRecord> left,
|
||||
List<FeatureFlagRecord> right,
|
||||
) {
|
||||
final rightIds = right.map((record) => record.id).toSet();
|
||||
return left
|
||||
.where((record) => !rightIds.contains(record.id))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Map<String, List<GitCommit>> _categorizeCommits(List<GitCommit> commits) {
|
||||
final ordered = <String, List<GitCommit>>{
|
||||
'Features': <GitCommit>[],
|
||||
'Fixes': <GitCommit>[],
|
||||
'Build / Release': <GitCommit>[],
|
||||
'Docs': <GitCommit>[],
|
||||
'Tests': <GitCommit>[],
|
||||
'Refactors': <GitCommit>[],
|
||||
'Merges': <GitCommit>[],
|
||||
'Other': <GitCommit>[],
|
||||
};
|
||||
|
||||
for (final commit in commits) {
|
||||
final subject = commit.subject.toLowerCase();
|
||||
final bucket = switch (true) {
|
||||
_ when subject.startsWith('merge ') => 'Merges',
|
||||
_
|
||||
when subject.startsWith('feat') ||
|
||||
subject.startsWith('add ') ||
|
||||
subject.startsWith('implement ') =>
|
||||
'Features',
|
||||
_ when subject.startsWith('fix') || subject.contains(' bug') => 'Fixes',
|
||||
_ when subject.startsWith('docs') || subject.startsWith('readme') =>
|
||||
'Docs',
|
||||
_ when subject.startsWith('test') => 'Tests',
|
||||
_ when subject.startsWith('refactor') => 'Refactors',
|
||||
_
|
||||
when subject.startsWith('build') ||
|
||||
subject.startsWith('release') ||
|
||||
subject.startsWith('ci') ||
|
||||
subject.startsWith('package') ||
|
||||
subject.contains('workflow') =>
|
||||
'Build / Release',
|
||||
_ => 'Other',
|
||||
};
|
||||
ordered[bucket]!.add(commit);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
String _inlineValue(String value) {
|
||||
final normalized = value.trim().isEmpty ? '-' : value.trim();
|
||||
return '`${_escapeMarkdown(normalized)}`';
|
||||
}
|
||||
|
||||
String _escapeMarkdown(String value) {
|
||||
return value.replaceAll('|', r'\|');
|
||||
}
|
||||
|
||||
String _titleCase(String value) {
|
||||
if (value.isEmpty) {
|
||||
return value;
|
||||
}
|
||||
return '${value[0].toUpperCase()}${value.substring(1)}';
|
||||
}
|
||||
|
||||
class FeatureManifest {
|
||||
FeatureManifest({required this.releasePolicy, required this.records});
|
||||
|
||||
factory FeatureManifest.load() {
|
||||
final yaml = loadYaml(File('config/feature_flags.yaml').readAsStringSync());
|
||||
final root = yaml as YamlMap;
|
||||
final releasePolicyRoot = root['release_policy'] as YamlMap? ?? YamlMap();
|
||||
final releasePolicy = <String, List<String>>{
|
||||
for (final buildMode in _buildModeOrder)
|
||||
buildMode: (releasePolicyRoot[buildMode] as YamlList? ?? YamlList())
|
||||
.map((value) => value.toString())
|
||||
.toList(growable: false),
|
||||
};
|
||||
|
||||
final records = <FeatureFlagRecord>[];
|
||||
for (final platform in _platformOrder) {
|
||||
final platformRoot = root[platform] as YamlMap?;
|
||||
if (platformRoot == null) {
|
||||
continue;
|
||||
}
|
||||
for (final moduleEntry in platformRoot.entries) {
|
||||
final module = moduleEntry.key.toString();
|
||||
final featureRoot = moduleEntry.value as YamlMap;
|
||||
for (final featureEntry in featureRoot.entries) {
|
||||
final name = featureEntry.key.toString();
|
||||
final raw = featureEntry.value as YamlMap;
|
||||
records.add(
|
||||
FeatureFlagRecord(
|
||||
platform: platform,
|
||||
module: module,
|
||||
name: name,
|
||||
enabled: raw['enabled'] == true,
|
||||
releaseTier: raw['release_tier'].toString(),
|
||||
buildModes: (raw['build_modes'] as YamlList? ?? YamlList())
|
||||
.map((value) => value.toString())
|
||||
.toList(growable: false),
|
||||
description: raw['description'].toString(),
|
||||
uiSurface: raw['ui_surface'].toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return FeatureManifest(releasePolicy: releasePolicy, records: records);
|
||||
}
|
||||
|
||||
final Map<String, List<String>> releasePolicy;
|
||||
final List<FeatureFlagRecord> records;
|
||||
|
||||
List<FeatureFlagRecord> recordsFor(String platform) {
|
||||
return records
|
||||
.where((record) => record.platform == platform)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<FeatureFlagRecord> visibleFlags(String platform, String buildMode) {
|
||||
return recordsFor(platform)
|
||||
.where((record) => record.visibleIn(buildMode, releasePolicy))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
int visibleFlagCount(String buildMode) {
|
||||
return _platformOrder.fold<int>(
|
||||
0,
|
||||
(total, platform) => total + visibleFlags(platform, buildMode).length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FeatureFlagRecord {
|
||||
const FeatureFlagRecord({
|
||||
required this.platform,
|
||||
required this.module,
|
||||
required this.name,
|
||||
required this.enabled,
|
||||
required this.releaseTier,
|
||||
required this.buildModes,
|
||||
required this.description,
|
||||
required this.uiSurface,
|
||||
});
|
||||
|
||||
final String platform;
|
||||
final String module;
|
||||
final String name;
|
||||
final bool enabled;
|
||||
final String releaseTier;
|
||||
final List<String> buildModes;
|
||||
final String description;
|
||||
final String uiSurface;
|
||||
|
||||
String get id => '$module.$name';
|
||||
|
||||
String get qualifiedId => '$platform.$module.$name';
|
||||
|
||||
bool visibleIn(String buildMode, Map<String, List<String>> releasePolicy) {
|
||||
final allowedTiers = releasePolicy[buildMode] ?? const <String>[];
|
||||
return enabled &&
|
||||
buildModes.contains(buildMode) &&
|
||||
allowedTiers.contains(releaseTier);
|
||||
}
|
||||
}
|
||||
|
||||
class GitSnapshot {
|
||||
GitSnapshot({
|
||||
required this.branch,
|
||||
required this.headShort,
|
||||
required this.headLong,
|
||||
required this.headTags,
|
||||
required this.latestTag,
|
||||
required this.previousTag,
|
||||
required this.comparisonRangeLabel,
|
||||
required this.generatedAt,
|
||||
required this.commits,
|
||||
required this.recentTags,
|
||||
});
|
||||
|
||||
factory GitSnapshot.capture() {
|
||||
final branch =
|
||||
_git(['branch', '--show-current'], allowFailure: true).trim().isEmpty
|
||||
? 'detached-head'
|
||||
: _git(['branch', '--show-current']);
|
||||
final headShort = _git(['rev-parse', '--short', 'HEAD']);
|
||||
final headLong = _git(['rev-parse', 'HEAD']);
|
||||
final headTags = _gitLines(['tag', '--points-at', 'HEAD']);
|
||||
final recentTags = _gitTagRefs().take(5).toList(growable: false);
|
||||
final latestTag = recentTags.isEmpty ? null : recentTags.first.name;
|
||||
|
||||
String? previousTag;
|
||||
String comparisonRangeLabel;
|
||||
String? comparisonRange;
|
||||
|
||||
if (headTags.isNotEmpty) {
|
||||
previousTag = recentTags
|
||||
.map((tag) => tag.name)
|
||||
.firstWhere((tag) => !headTags.contains(tag), orElse: () => '')
|
||||
.trim();
|
||||
if (previousTag.isEmpty) {
|
||||
previousTag = null;
|
||||
}
|
||||
final activeTag = headTags.first;
|
||||
comparisonRange = previousTag == null ? null : '$previousTag..$activeTag';
|
||||
comparisonRangeLabel = comparisonRange ?? activeTag;
|
||||
} else if (latestTag != null) {
|
||||
previousTag = recentTags.length > 1 ? recentTags[1].name : null;
|
||||
comparisonRange = '$latestTag..HEAD';
|
||||
comparisonRangeLabel = comparisonRange;
|
||||
} else {
|
||||
comparisonRange = null;
|
||||
comparisonRangeLabel = 'HEAD (latest 20 commits)';
|
||||
}
|
||||
|
||||
final commits = _gitCommitLog(comparisonRange);
|
||||
|
||||
return GitSnapshot(
|
||||
branch: branch,
|
||||
headShort: headShort,
|
||||
headLong: headLong,
|
||||
headTags: headTags,
|
||||
latestTag: latestTag,
|
||||
previousTag: previousTag,
|
||||
comparisonRangeLabel: comparisonRangeLabel,
|
||||
generatedAt: DateTime.now().toIso8601String(),
|
||||
commits: commits,
|
||||
recentTags: recentTags,
|
||||
);
|
||||
}
|
||||
|
||||
final String branch;
|
||||
final String headShort;
|
||||
final String headLong;
|
||||
final List<String> headTags;
|
||||
final String? latestTag;
|
||||
final String? previousTag;
|
||||
final String comparisonRangeLabel;
|
||||
final String generatedAt;
|
||||
final List<GitCommit> commits;
|
||||
final List<GitTagRef> recentTags;
|
||||
}
|
||||
|
||||
class GitCommit {
|
||||
const GitCommit({
|
||||
required this.hash,
|
||||
required this.date,
|
||||
required this.author,
|
||||
required this.subject,
|
||||
});
|
||||
|
||||
final String hash;
|
||||
final String date;
|
||||
final String author;
|
||||
final String subject;
|
||||
}
|
||||
|
||||
class GitTagRef {
|
||||
const GitTagRef({required this.name, required this.date});
|
||||
|
||||
final String name;
|
||||
final String date;
|
||||
}
|
||||
|
||||
List<GitCommit> _gitCommitLog(String? comparisonRange) {
|
||||
final args = <String>[
|
||||
'log',
|
||||
'--date=short',
|
||||
'--pretty=format:%h%x09%ad%x09%an%x09%s',
|
||||
];
|
||||
|
||||
if (comparisonRange == null) {
|
||||
args.addAll(<String>['-n', '20']);
|
||||
} else {
|
||||
args.add(comparisonRange);
|
||||
}
|
||||
|
||||
final lines = _gitLines(args, allowFailure: true);
|
||||
return lines
|
||||
.map((line) => line.split('\t'))
|
||||
.where((parts) => parts.length >= 4)
|
||||
.map(
|
||||
(parts) => GitCommit(
|
||||
hash: parts[0],
|
||||
date: parts[1],
|
||||
author: parts[2],
|
||||
subject: parts.sublist(3).join('\t'),
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<GitTagRef> _gitTagRefs() {
|
||||
final lines = _gitLines(<String>[
|
||||
'for-each-ref',
|
||||
'--sort=-creatordate',
|
||||
'--format=%(refname:short)%09%(creatordate:short)',
|
||||
'refs/tags',
|
||||
], allowFailure: true);
|
||||
|
||||
return lines
|
||||
.map((line) => line.split('\t'))
|
||||
.where((parts) => parts.length >= 2)
|
||||
.map((parts) => GitTagRef(name: parts[0], date: parts[1]))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _git(List<String> args, {bool allowFailure = false}) {
|
||||
final result = Process.runSync('git', args);
|
||||
if (result.exitCode != 0) {
|
||||
if (allowFailure) {
|
||||
return '';
|
||||
}
|
||||
throw ProcessException(
|
||||
'git',
|
||||
args,
|
||||
(result.stderr as String).trim(),
|
||||
result.exitCode,
|
||||
);
|
||||
}
|
||||
return (result.stdout as String).trim();
|
||||
}
|
||||
|
||||
List<String> _gitLines(List<String> args, {bool allowFailure = false}) {
|
||||
final output = _git(args, allowFailure: allowFailure);
|
||||
if (output.trim().isEmpty) {
|
||||
return const <String>[];
|
||||
}
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.where((line) => line.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
BIN
web/favicon.png
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 176 KiB |