diff --git a/Makefile b/Makefile index 7563ac41..d7cc49e1 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ PNPM ?= pnpm DART ?= dart DEVICE ?= macos -.PHONY: help deps analyze test check format run build-linux build-macos build-ios-sim package-deb package-rpm package-linux package-mac install-mac clean build-aris-bridge +.PHONY: help deps analyze test check format run build-linux build-macos build-ios-sim package-deb package-rpm package-linux package-mac install-mac clean build-aris-bridge render-release-docs help: ## Show available targets @grep -E '^[a-zA-Z0-9_.-]+:.*?## ' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-18s %s\n", $$1, $$2}' @@ -26,6 +26,9 @@ check: analyze test ## Run the standard validation suite format: ## Format Dart sources $(DART) format lib test +render-release-docs: ## Render feature matrix, roadmap, release notes, and changelog docs + $(DART) run tool/render_release_docs.dart + run: ## Run the app on a device or desktop target (DEVICE=macos by default) $(FLUTTER) run -d $(DEVICE) diff --git a/README.md b/README.md index 6c9b8bb6..1e5520ed 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/assets/branding/xmate_desktop_logo.png b/assets/branding/xmate_desktop_logo.png new file mode 100644 index 00000000..47e5bfe4 Binary files /dev/null and b/assets/branding/xmate_desktop_logo.png differ diff --git a/config/feature_flags.yaml b/config/feature_flags.yaml new file mode 100644 index 00000000..b3be0dfe --- /dev/null +++ b/config/feature_flags.yaml @@ -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 diff --git a/docs/planning/xworkmate-ui-feature-matrix.md b/docs/planning/xworkmate-ui-feature-matrix.md new file mode 100644 index 00000000..62b79289 --- /dev/null +++ b/docs/planning/xworkmate-ui-feature-matrix.md @@ -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 | + diff --git a/docs/planning/xworkmate-ui-feature-roadmap.md b/docs/planning/xworkmate-ui-feature-roadmap.md new file mode 100644 index 00000000..ff48b22e --- /dev/null +++ b/docs/planning/xworkmate-ui-feature-roadmap.md @@ -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` + diff --git a/docs/releases/xworkmate-changelog.md b/docs/releases/xworkmate-changelog.md new file mode 100644 index 00000000..e5fc4744 --- /dev/null +++ b/docs/releases/xworkmate-changelog.md @@ -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 | diff --git a/docs/releases/xworkmate-release-notes.md b/docs/releases/xworkmate-release-notes.md new file mode 100644 index 00000000..7501606c --- /dev/null +++ b/docs/releases/xworkmate-release-notes.md @@ -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 + diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada47..b9c298d3 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41e..3d58caa1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452e..2bb0be2d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d933..e526672d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b009..451c38ea 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe730945..770eab24 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773cd..3594b6e8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452e..2bb0be2d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463a..e5ac6ba0 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec30343..4037b09b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec30343..4037b09b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea2..f1119c01 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32ae..4ffd17d0 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba0..2bcb3c30 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf12..eeaf2e6b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/app/app.dart b/lib/app/app.dart index a811a000..41565ea7 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -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 createState() => _XWorkmateAppState(); @@ -20,7 +23,9 @@ class _XWorkmateAppState extends State { @override void initState() { super.initState(); - _controller = AppController(); + _controller = AppController( + uiFeatureManifest: widget.featureManifest ?? UiFeatureManifest.fallback(), + ); } @override diff --git a/lib/app/app_capabilities.dart b/lib/app/app_capabilities.dart index af3f5d0f..de591ce3 100644 --- a/lib/app/app_capabilities.dart +++ b/lib/app/app_capabilities.dart @@ -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.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.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, + ); + } } diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index cdebe35f..e019638a 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -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 get assistantNavigationDestinations => normalizeAssistantNavigationDestinations( settings.assistantNavigationDestinations, - ); + ).where(capabilities.supportsDestination).toList(growable: false); List 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 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; diff --git a/lib/app/app_controller_web.dart b/lib/app/app_controller_web.dart index 217f0db5..26d12b21 100644 --- a/lib/app/app_controller_web.dart +++ b/lib/app/app_controller_web.dart @@ -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 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 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({ diff --git a/lib/app/app_metadata.dart b/lib/app/app_metadata.dart index 48dbe852..7575df29 100644 --- a/lib/app/app_metadata.dart +++ b/lib/app/app_metadata.dart @@ -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', diff --git a/lib/app/app_shell_desktop.dart b/lib/app/app_shell_desktop.dart index 1c69bca9..83576eab 100644 --- a/lib/app/app_shell_desktop.dart +++ b/lib/app/app_shell_desktop.dart @@ -89,6 +89,15 @@ class _AppShellState extends State { 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( @@ -151,7 +160,7 @@ class _AppShellState extends State { child: Container( color: palette.canvas.withValues(alpha: 0.18), child: _pageForDestination( - mobileDestination, + resolvedMobileDestination, openMobileDetail, ), ), @@ -163,15 +172,18 @@ class _AppShellState extends State { 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 { 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 { .toSet(), onToggleFavorite: controller.toggleAssistantNavigationDestination, + availableDestinations: + controller.capabilities.allowedDestinations, ), if (sidebarState == AppSidebarState.expanded && !embedSidebarIntoAssistant) diff --git a/lib/app/app_shell_web.dart b/lib/app/app_shell_web.dart index 984ae0de..2f21b48c 100644 --- a/lib/app/app_shell_web.dart +++ b/lib/app/app_shell_web.dart @@ -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.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), }; diff --git a/lib/app/ui_feature_manifest.dart b/lib/app/ui_feature_manifest.dart new file mode 100644 index 00000000..7c2f6fe1 --- /dev/null +++ b/lib/app/ui_feature_manifest.dart @@ -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 buildModes; + final String description; + final String uiSurface; + + UiFeatureFlag copyWith({ + bool? enabled, + UiFeatureReleaseTier? releaseTier, + Set? 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>> + 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> releasePolicy; + final Map>> + _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 = + >>{}; + 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? 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 = + >>{}; + 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> _parseReleasePolicy( + Object? raw, + ) { + if (raw is! YamlMap) { + throw const FormatException( + 'release_policy must define debug/profile/release tiers.', + ); + } + final policy = >{}; + 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> _parsePlatformModules({ + required UiFeaturePlatform platform, + required Object? raw, + }) { + if (raw is! YamlMap) { + throw FormatException('${platform.name} must be a YAML map.'); + } + final modules = >{}; + 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 = {}; + 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 = { + '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> + _destinationMappings = >{ + UiFeaturePlatform.mobile: { + 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: { + 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: { + UiFeatureKeys.navigationAssistant: WorkspaceDestination.assistant, + UiFeatureKeys.navigationSettings: WorkspaceDestination.settings, + }, + }; + + static const Map _settingsTabMappings = + { + 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 get allowedDestinations { + final mappings = _destinationMappings[platform] ?? const {}; + final allowed = {}; + 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 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 get availableExecutionTargets { + final targets = []; + 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.aiGatewayOnly, + AssistantExecutionTarget.remote, + ] + : const [ + 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 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(); + } + } +} diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 8c366a6c..ba0aed57 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -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 { } Future _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 { Future _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 { ); }); - 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( - 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( - value: 'attach', - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.attach_file_rounded), - title: Text('添加照片和文件'), + if (uiFeatures.supportsFileAttachments) ...[ + PopupMenuButton( + 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( + 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( 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( 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), diff --git a/lib/features/mobile/mobile_shell.dart b/lib/features/mobile/mobile_shell.dart index 817dfa24..72f95ff3 100644 --- a/lib/features/mobile/mobile_shell.dart +++ b/lib/features/mobile/mobile_shell.dart @@ -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 { } 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 { return AnimatedBuilder( animation: widget.controller, builder: (context, _) { + final features = widget.controller.featuresFor( + UiFeaturePlatform.mobile, + ); + final availableTabs = [ + 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('mobile-shell-workspace') : ValueKey( @@ -260,7 +279,9 @@ class _MobileShellState extends State { 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 { 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 tabs; final ValueChanged 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, + ), ), ], ), diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index ec6d33ab..7b7c9dd4 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -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 { 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 { 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 { ), const SizedBox(height: 24), ], - ..._buildContentForCurrentState(context, controller, settings), + ..._buildContentForCurrentState( + context, + controller, + settings, + uiFeatures, + ), ], ), ); @@ -185,6 +194,7 @@ class _SettingsPageState extends State { 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 { context, controller, settings, + uiFeatures, ), SettingsTab.about => _buildAbout(context, controller), }; @@ -1589,7 +1600,8 @@ class _SettingsPageState extends State { ), 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 { 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 { 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 { ), const SizedBox(height: 12), _AgentRoleCard( - title: '🧪 ${appText('Worker/Review(Worker 池)', 'Worker/Review Pool')}', + title: + '🧪 ${appText('Worker/Review(Worker 池)', 'Worker/Review Pool')}', description: appText( '负责 glm/qwen worker lane、回归审阅和补充建议。', 'Owns glm/qwen worker lanes, review, regression checks, and follow-up notes.', @@ -1665,10 +1682,12 @@ class _SettingsPageState extends State { 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 { _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 { BuildContext context, AppController controller, SettingsSnapshot settings, + UiFeatureAccess uiFeatures, ) { + final toggles = [ + 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 { 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, ], ), ), diff --git a/lib/main.dart b/lib/main.dart index 0cf35564..4b1045b6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { + WidgetsFlutterBinding.ensureInitialized(); + final featureManifest = await UiFeatureManifestLoader.load(); + runApp(XWorkmateApp(featureManifest: featureManifest)); } diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index c4b27327..240be8a0 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -233,6 +233,7 @@ class AppTheme { ); return base.copyWith( + platform: resolvedPlatform, splashFactory: NoSplash.splashFactory, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: isDesktop diff --git a/lib/web/web_assistant_page.dart b/lib/web/web_assistant_page.dart index a49faa30..a39b8d06 100644 --- a/lib/web/web_assistant_page.dart +++ b/lib/web/web_assistant_page.dart @@ -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 { 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 { 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 { label: Text(appText('连接设置', 'Connection settings')), ), _TargetChip( + targets: availableTargets, value: currentTarget, onChanged: (value) { if (value != null) { @@ -118,6 +128,8 @@ class _WebAssistantPageState extends State { _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 onQueryChanged; final VoidCallback onClearQuery; + final bool showDirect; + final bool showRelay; final List direct; final List 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 targets; final AssistantExecutionTarget value; final ValueChanged onChanged; @@ -599,18 +622,14 @@ class _TargetChip extends StatelessWidget { child: DropdownButton( value: value, onChanged: onChanged, - items: - const [ - AssistantExecutionTarget.aiGatewayOnly, - AssistantExecutionTarget.remote, - ] - .map((target) { - return DropdownMenuItem( - value: target, - child: Text(_targetLabel(target)), - ); - }) - .toList(growable: false), + items: targets + .map((target) { + return DropdownMenuItem( + value: target, + child: Text(_targetLabel(target)), + ); + }) + .toList(growable: false), ), ); } diff --git a/lib/web/web_settings_page.dart b/lib/web/web_settings_page.dart index 7b3ca3bf..40fc894b 100644 --- a/lib/web/web_settings_page.dart +++ b/lib/web/web_settings_page.dart @@ -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 { 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( @@ -159,15 +164,10 @@ class _WebSettingsPageState extends State { child: Column( children: [ SectionTabs( - items: const [ - 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 { } List _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 { const SizedBox(height: 10), DropdownButtonFormField( initialValue: controller.assistantExecutionTarget, - items: - const [ - AssistantExecutionTarget.aiGatewayOnly, - AssistantExecutionTarget.remote, - ] - .map((target) { - return DropdownMenuItem( - value: target, - child: Text(_targetLabel(target)), - ); - }) - .toList(growable: false), + items: targets + .map((target) { + return DropdownMenuItem( + value: target, + child: Text(_targetLabel(target)), + ); + }) + .toList(growable: false), onChanged: (value) { if (value != null) { controller.setAssistantExecutionTarget(value); diff --git a/lib/widgets/app_brand_logo.dart b/lib/widgets/app_brand_logo.dart new file mode 100644 index 00000000..571af5e7 --- /dev/null +++ b/lib/widgets/app_brand_logo.dart @@ -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, + ), + ), + ); + } +} diff --git a/lib/widgets/assistant_focus_panel.dart b/lib/widgets/assistant_focus_panel.dart index 12776a57..1222a8e3 100644 --- a/lib/widgets/assistant_focus_panel.dart +++ b/lib/widgets/assistant_focus_panel.dart @@ -48,6 +48,7 @@ class _AssistantFocusPanelState extends State { 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); diff --git a/lib/widgets/gateway_connect_dialog.dart b/lib/widgets/gateway_connect_dialog.dart index d7f48376..c0f3a417 100644 --- a/lib/widgets/gateway_connect_dialog.dart +++ b/lib/widgets/gateway_connect_dialog.dart @@ -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 { _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 { @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 { decoration: InputDecoration( labelText: appText('工作模式', 'Work Mode'), ), - items: RuntimeConnectionMode.values + items: availableConnectionModes .map( (mode) => DropdownMenuItem( value: mode, @@ -456,6 +470,30 @@ class _GatewayConnectDialogState extends State { } } } + + List _availableConnectionModes( + UiFeatureAccess uiFeatures, + ) { + return [ + 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 { diff --git a/lib/widgets/sidebar_navigation.dart b/lib/widgets/sidebar_navigation.dart index f96204c1..f55fbfa4 100644 --- a/lib/widgets/sidebar_navigation.dart +++ b/lib/widgets/sidebar_navigation.dart @@ -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 {}, this.onToggleFavorite, }); @@ -44,6 +46,7 @@ class SidebarNavigation extends StatelessWidget { final double? expandedWidthOverride; final EdgeInsetsGeometry? marginOverride; final bool showCollapseControl; + final Set? availableDestinations; final Set favoriteDestinations; final Future 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 _filterSections( + List 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, + ), ], ); } diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 82b6f9d9..b9c298d3 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 13b35eba..b1ee5370 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 0a3f5fa4..867a5edb 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index bdb57226..d0061889 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index f083318e..771f7e9d 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 326c0e72..b7c4e5b6 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 2f1632cf..fac626bd 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/pubspec.yaml b/pubspec.yaml index bd67fd27..2ec55256 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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/ diff --git a/test/app/ui_feature_manifest_test.dart b/test/app/ui_feature_manifest_test.dart new file mode 100644 index 00000000..de7daadf --- /dev/null +++ b/test/app/ui_feature_manifest_test.dart @@ -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.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, + ); + }); +} diff --git a/test/features/ai_gateway_page_test.dart b/test/features/ai_gateway_page_test.dart index 57285651..92d21617 100644 --- a/test/features/ai_gateway_page_test.dart +++ b/test/features/ai_gateway_page_test.dart @@ -174,7 +174,7 @@ void main() { } Future _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'); diff --git a/test/features/assistant_page_test.dart b/test/features/assistant_page_test.dart index 1541fca2..2132cdca 100644 --- a/test/features/assistant_page_test.dart +++ b/test/features/assistant_page_test.dart @@ -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 { diff --git a/test/features/mobile/ios_mobile_shell_test.dart b/test/features/mobile/ios_mobile_shell_test.dart index 9efc3b76..138a0869 100644 --- a/test/features/mobile/ios_mobile_shell_test.dart +++ b/test/features/mobile/ios_mobile_shell_test.dart @@ -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, diff --git a/test/features/settings_ai_gateway_persistence_test.dart b/test/features/settings_ai_gateway_persistence_test.dart index 084b4be7..9cf68ac0 100644 --- a/test/features/settings_ai_gateway_persistence_test.dart +++ b/test/features/settings_ai_gateway_persistence_test.dart @@ -116,7 +116,7 @@ void main() { } Future _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'); diff --git a/test/features/settings_page_test.dart b/test/features/settings_page_test.dart index ad7e258a..26edae97 100644 --- a/test/features/settings_page_test.dart +++ b/test/features/settings_page_test.dart @@ -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 { diff --git a/test/runtime/app_controller_navigation_favorites_test.dart b/test/runtime/app_controller_navigation_favorites_test.dart index 4e199a5f..a27cb66d 100644 --- a/test/runtime/app_controller_navigation_favorites_test.dart +++ b/test/runtime/app_controller_navigation_favorites_test.dart @@ -72,7 +72,7 @@ void main() { Future _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()) { diff --git a/test/test_support.dart b/test/test_support.dart index 53c55469..6ab85312 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -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 createTestController( WidgetTester tester, { DesktopPlatformService? desktopPlatformService, + UiFeatureManifest? uiFeatureManifest, }) async { SharedPreferences.setMockInitialValues({}); final controller = AppController( @@ -21,6 +23,7 @@ Future createTestController( '${Directory.systemTemp.path}/xworkmate-widget-tests', ), desktopPlatformService: desktopPlatformService, + uiFeatureManifest: uiFeatureManifest, ); addTearDown(controller.dispose); await tester.pump(const Duration(milliseconds: 100)); diff --git a/tool/render_release_docs.dart b/tool/render_release_docs.dart new file mode 100644 index 00000000..5699c60e --- /dev/null +++ b/tool/render_release_docs.dart @@ -0,0 +1,791 @@ +import 'dart:io'; + +import 'package:yaml/yaml.dart'; + +const _platformOrder = ['mobile', 'desktop', 'web']; +const _tierOrder = ['stable', 'beta', 'experimental']; +const _buildModeOrder = ['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 []; + 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 = {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 = {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 = []; + final debugOnlyAll = []; + + 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 records, { + bool includePlatform = false, +}) { + if (records.isEmpty) { + return '-'; + } + return records + .map( + (record) => + '`${_escapeMarkdown(includePlatform ? record.qualifiedId : record.id)}`', + ) + .join(', '); +} + +List _difference( + List left, + List right, +) { + final rightIds = right.map((record) => record.id).toSet(); + return left + .where((record) => !rightIds.contains(record.id)) + .toList(growable: false); +} + +Map> _categorizeCommits(List commits) { + final ordered = >{ + 'Features': [], + 'Fixes': [], + 'Build / Release': [], + 'Docs': [], + 'Tests': [], + 'Refactors': [], + 'Merges': [], + 'Other': [], + }; + + 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 = >{ + for (final buildMode in _buildModeOrder) + buildMode: (releasePolicyRoot[buildMode] as YamlList? ?? YamlList()) + .map((value) => value.toString()) + .toList(growable: false), + }; + + final records = []; + 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> releasePolicy; + final List records; + + List recordsFor(String platform) { + return records + .where((record) => record.platform == platform) + .toList(growable: false); + } + + List visibleFlags(String platform, String buildMode) { + return recordsFor(platform) + .where((record) => record.visibleIn(buildMode, releasePolicy)) + .toList(growable: false); + } + + int visibleFlagCount(String buildMode) { + return _platformOrder.fold( + 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 buildModes; + final String description; + final String uiSurface; + + String get id => '$module.$name'; + + String get qualifiedId => '$platform.$module.$name'; + + bool visibleIn(String buildMode, Map> releasePolicy) { + final allowedTiers = releasePolicy[buildMode] ?? const []; + 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 headTags; + final String? latestTag; + final String? previousTag; + final String comparisonRangeLabel; + final String generatedAt; + final List commits; + final List 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 _gitCommitLog(String? comparisonRange) { + final args = [ + 'log', + '--date=short', + '--pretty=format:%h%x09%ad%x09%an%x09%s', + ]; + + if (comparisonRange == null) { + args.addAll(['-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 _gitTagRefs() { + final lines = _gitLines([ + '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 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 _gitLines(List args, {bool allowFailure = false}) { + final output = _git(args, allowFailure: allowFailure); + if (output.trim().isEmpty) { + return const []; + } + return output + .split('\n') + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .toList(growable: false); +} diff --git a/web/favicon.png b/web/favicon.png index 8aaa46ac..fac626bd 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index b749bfef..a7366e8c 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 88cfd48d..b7c4e5b6 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index eb9b4d76..a7366e8c 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index d69c5669..b7c4e5b6 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ