feat: add ui feature flag release docs pipeline

This commit is contained in:
Haitao Pan 2026-03-22 10:49:13 +08:00
parent 1ca505408a
commit 7378888ed4
64 changed files with 3571 additions and 499 deletions

View File

@ -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)

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

434
config/feature_flags.yaml Normal file
View 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

View 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 |

View 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`

View 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 |

View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -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

View File

@ -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,
);
}
}

View File

@ -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;

View File

@ -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({

View File

@ -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',

View File

@ -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)

View File

@ -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),
};

View 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();
}
}
}

View File

@ -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),

View File

@ -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,
),
),
],
),

View File

@ -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/ReviewWorker 池)', 'Worker/Review Pool')}',
title:
'🧪 ${appText('Worker/ReviewWorker 池)', '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,
],
),
),

View File

@ -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));
}

View File

@ -233,6 +233,7 @@ class AppTheme {
);
return base.copyWith(
platform: resolvedPlatform,
splashFactory: NoSplash.splashFactory,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: isDesktop

View File

@ -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),
),
);
}

View File

@ -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);

View 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,
),
),
);
}
}

View File

@ -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);

View File

@ -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 {

View File

@ -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,
),
],
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -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/

View 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,
);
});
}

View File

@ -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');

View File

@ -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 {

View File

@ -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,

View File

@ -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');

View File

@ -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 {

View File

@ -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()) {

View File

@ -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));

View 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);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 176 KiB