From 72bbe6c1f9eaf3c2e494f1bde32185eb0fa71a51 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 27 Aug 2026 07:29:14 -0700 Subject: [PATCH 1/4] feat: complete Creative mode --- docs/architecture/product-architecture.md | 8 +- .../rust-build-dependency-boundaries.md | 3 +- scripts/check-core-boundaries.test.mjs | 1 + .../cargo-dependency-boundaries.mjs | 1 + .../core-boundaries/rules/feature-rules.mjs | 12 + scripts/desktop-tauri-build.mjs | 12 + scripts/desktop-tauri-build.test.mjs | 14 + src/apps/cli/src/peer_host/deny.rs | 8 + .../bootstrap-ui/frontend-update-confirm.html | 68 ++ src/apps/desktop/bootstrap-ui/index.html | 21 + src/apps/desktop/capabilities/default.json | 2 +- src/apps/desktop/src/api/peer_host_invoke.rs | 8 + .../src/api/remote_workspace_policy.rs | 13 + src/apps/desktop/src/appearance.rs | 16 +- src/apps/desktop/src/frontend_workbench.rs | 939 ++++++++++++++++++ src/apps/desktop/src/lib.rs | 43 + src/apps/desktop/tauri.conf.json | 2 +- src/apps/desktop/tauri.dev.conf.json | 2 +- .../creative_mode_first_entry_reminder.md | 6 + .../tests/prompt_catalog_contracts.rs | 4 + src/crates/assembly/core/Cargo.toml | 4 + .../bitfun-frontend-dev/SKILL.md | 28 + .../agentic/agents/definitions/modes/claw.rs | 11 +- .../agents/definitions/modes/cowork.rs | 11 +- .../agents/definitions/modes/creative.rs | 135 +++ .../agentic/agents/definitions/modes/mod.rs | 2 + .../assembly/core/src/agentic/agents/mod.rs | 7 +- .../src/agentic/agents/registry/catalog.rs | 10 +- .../core/src/agentic/agents/registry/tests.rs | 27 + .../src/agentic/tools/agent-tool-exposure.md | 1 + .../agentic/tools/frontend_workbench_host.rs | 42 + .../frontend_workbench_tool.rs | 229 +++++ .../src/agentic/tools/implementations/mod.rs | 4 + .../tools/implementations/skills/catalog.rs | 4 + .../assembly/core/src/agentic/tools/mod.rs | 2 + .../tools/product_runtime/materialization.rs | 3 + .../assembly/product-capabilities/src/lib.rs | 15 + .../product_capabilities.rs | 8 + .../execution/agent-runtime/src/agents.rs | 11 +- .../agent-runtime/src/skills/catalog.rs | 6 + .../agent-runtime/src/skills/policy.rs | 49 + .../agent_registry_contracts.rs | 2 + .../skill_contracts.rs | 18 +- .../execution/tool-provider-groups/Cargo.toml | 3 +- .../execution/tool-provider-groups/src/lib.rs | 20 + src/web-ui/index.html | 3 + src/web-ui/public/bitfun-creation.css | 5 + src/web-ui/public/bitfun-creation.js | 4 + .../miniapps/views/MiniAppGalleryView.tsx | 63 +- .../HarnessProfileSelector.test.tsx | 17 +- .../components/HarnessProfileSelector.tsx | 26 +- .../chatInputExecutionLevelPolicy.test.ts | 4 + .../utils/chatInputExecutionLevelPolicy.ts | 4 +- .../api/adapters/peer-device-adapter.test.ts | 5 + .../api/adapters/peer-device-adapter.ts | 2 + .../config/components/MemorySettingsPage.tsx | 23 +- .../src/locales/en-US/scenes/miniapp.json | 7 +- .../src/locales/zh-CN/scenes/miniapp.json | 7 +- .../src/locales/zh-TW/scenes/miniapp.json | 7 +- tests/e2e/specs/l0-appearance.spec.ts | 17 +- .../l1-chat-input-contract-controls.spec.ts | 7 +- 61 files changed, 1907 insertions(+), 129 deletions(-) create mode 100644 src/apps/desktop/bootstrap-ui/frontend-update-confirm.html create mode 100644 src/apps/desktop/bootstrap-ui/index.html create mode 100644 src/apps/desktop/src/frontend_workbench.rs create mode 100644 src/crates/assembly/agent-content/prompts/agents/creative_mode_first_entry_reminder.md create mode 100644 src/crates/assembly/core/builtin_skills/bitfun-frontend-dev/SKILL.md create mode 100644 src/crates/assembly/core/src/agentic/agents/definitions/modes/creative.rs create mode 100644 src/crates/assembly/core/src/agentic/tools/frontend_workbench_host.rs create mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs create mode 100644 src/web-ui/public/bitfun-creation.css create mode 100644 src/web-ui/public/bitfun-creation.js diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 8d0fabb96b..b53cc421df 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -240,7 +240,7 @@ Assembly 是唯一组装根,只选择下层能力和实现,不能反向依 命名工作流的无 I/O 决策归 `agent-workflows`;具体 I/O 归 Services;`product-capabilities` 只选择工作流能力、Agent ID 和原子工具组。依赖方向固定为 `Assembly → agent-workflows → agent-runtime / contracts`,`agent-runtime` 禁止反向依赖 `agent-workflows`。当前 `agent-workflows` 的 DeepResearch 报告后处理不需要 Runtime 类型,因此直接依赖基础库;这不改变上述长期方向。 -原子逻辑模块应满足三个条件:一个明确 owner、一个可独立测试的输入输出契约、一个变化原因。`core.basic`、`core.agent`、`core.session`、`core.git`、`core.web`、`core.mcp`、`core.computer-use`、`core.review`、`core.miniapp`、`core.canvas` 分别表达实际工具职责;不得重新合成 `core.integration` 这类同时包含网络、Git、MCP、产品工具和平台能力的大组。 +原子逻辑模块应满足三个条件:一个明确 owner、一个可独立测试的输入输出契约、一个变化原因。`core.basic`、`core.agent`、`core.session`、`core.git`、`core.web`、`core.mcp`、`core.computer-use`、`core.review`、`core.miniapp`、`core.creation`、`core.canvas` 分别表达实际工具职责;其中 `core.creation` 只承载创造模式的产品创作入口,不吸收 MiniApp runtime/market owner。不得重新合成 `core.integration` 这类同时包含网络、Git、MCP、产品工具和平台能力的大组。 当前生产循环仍由 `assembly/core` 的 `ConversationCoordinator → Scheduler → ExecutionEngine → RoundExecutor → ToolPipeline` 持有;`agent-runtime` 已拥有可移植决策与 Rust preview facade,但尚未独立拥有完整循环。`assembly/core` 因此仍是过渡期兼容组装与实际 owner,不能被描述成只有 wiring。后续迁移必须逐条切换真实调用方、保留行为等价测试并删除旧写入方;移动 DTO、增加 feature 或 re-export 均不算 owner 迁移。当前仍位于 Runtime/Core 的 DeepReview 兼容逻辑只允许迁出和修复,不继续承接新的产品行为。 @@ -832,10 +832,10 @@ flowchart LR | 当前入口 | 已有能力 | 明确边界 | |---|---|---| -| Desktop | 使用 `product-full`;Settings 从现有来源目录和 integration policy 生成简短应用概览,具体审批与冲突仍进入 Tool、Agent、MCP 或 Hook owner | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | -| CLI / TUI | 使用 `DeliveryProfile::Cli` 和显式 Core owner closure;Runtime 只注册 Code Agent 清单及 `basic / agent / session / git / web / mcp / computer-use` 七个原子工具组,外部来源、插件和 Remote/SSH 仍由各自 owner 按入口需要装配 | 不注册 DeepReview、DeepResearch、MiniApp 或 Canvas Agent / Tool;非交互不等待权限输入,远程能力未接入时不回退本机 | +| Desktop | 使用 `product-full`,包含 Creative Agent 与 `core.creation`;Settings 从现有来源目录和 integration policy 生成简短应用概览,具体审批与冲突仍进入 Tool、Agent、MCP 或 Hook owner | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | +| CLI / TUI | 使用 `DeliveryProfile::Cli` 和显式 Core owner closure;Runtime 只注册 Code Agent 清单及 `basic / agent / session / git / web / mcp / computer-use` 七个原子工具组,外部来源、插件和 Remote/SSH 仍由各自 owner 按入口需要装配 | 不注册 DeepReview、DeepResearch、MiniApp、Creation 或 Canvas Agent / Tool;非交互不等待权限输入,远程能力未接入时不回退本机 | | ACP | 使用 `DeliveryProfile::Acp`、Runtime Parts、Code Agent 清单与同一组七个原子工具组,不选择 CLI 的 plugin runtime 和 Remote Connect owner | load 成功后才发布活动状态;完整历史和兼容配置仍由 Core/ACP 管理;未选择的产品工作流不得借 Cargo feature union 偶然注册 | -| SDK Host(preview) | 使用 `DeliveryProfile::Sdk`、Runtime Parts、Code Agent 清单和七个原子工具组;TLS provider 由 Host 进程入口安装 | 不注册 DeepReview、DeepResearch、MiniApp 或 Canvas;当前协议也不暴露远程 workspace/SSH,未来远程 SDK 必须复用 Server/Remote 的认证和执行域 | +| SDK Host(preview) | 使用 `DeliveryProfile::Sdk`、Runtime Parts、Code Agent 清单和七个原子工具组;TLS provider 由 Host 进程入口安装 | 不注册 DeepReview、DeepResearch、MiniApp、Creation 或 Canvas;当前协议也不暴露远程 workspace/SSH,未来远程 SDK 必须复用 Server/Remote 的认证和执行域 | | Peer / Server | Peer Host 执行真实工作区操作;通用 HTTP Server 未绑定可信 workspace owner 时明确返回不支持 | 控制端不替远端发现或执行;loopback 单用户边界不扩展到远程/多用户;SSH Remote 未接入时返回不支持 | | Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 | | HarmonyOS 手机 Remote | phone-only ArkTS 远程入口 | 不等于 HarmonyOS PC 本地 Runtime、CLI/TUI 或 GUI | diff --git a/docs/architecture/rust-build-dependency-boundaries.md b/docs/architecture/rust-build-dependency-boundaries.md index ee29a0d96e..44760f6a07 100644 --- a/docs/architecture/rust-build-dependency-boundaries.md +++ b/docs/architecture/rust-build-dependency-boundaries.md @@ -71,7 +71,8 @@ runtime dependency;真实产品入口必须同时显式选择 owner 与 modifi 把完整 adapter、service 或 tool runtime 拉回窄闭包。 Function Agent 的 Git/AI 适配由 `function-agents` 选择,MiniApp 的 domain/runtime/market -闭包由 `tools-miniapp` 选择;不得再通过一个通用 `product-domains` Core feature 把两者、 +闭包由 `tools-miniapp` 选择,创造模式专属的前端工作台由独立的 `tools-creation` 选择; +产品装配可以为 Creative 同时选择两个 owner,但不得再通过一个通用 `product-domains` Core feature 把它们、 Plugin Source 和完整 domain feature 集合一起带回 Agent Runtime。产品装配计划若声明了当前 二进制未编译的工具组,必须在 registry materialization 前明确失败,不能静默删掉该组。 diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 5fe22b357a..6045bdc4bd 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -1259,6 +1259,7 @@ test('Core product-full explicitly assembles service and tool capability owners' 'tools-mcp', 'tools-browser-web', 'tools-computer-use', + 'tools-creation', 'tools-image-analysis', 'tools-miniapp', 'tools-canvas', diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 20c6339bf2..f6cbae069e 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -1446,6 +1446,7 @@ export function findProductEntrypointCoreFeatureViolations( 'tools-browser-web', 'tools-canvas', 'tools-computer-use', + 'tools-creation', 'tools-git', 'tools-image-analysis', 'tools-mcp', diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 0204a8df9c..f4720269c2 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -212,6 +212,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'tools-browser-web', 'tools-canvas', 'tools-computer-use', + 'tools-creation', 'tools-git', 'tools-image-analysis', 'tools-mcp', @@ -678,6 +679,7 @@ export const coreProductFullFeatureAssemblyRule = { 'tools-browser-web', 'tools-canvas', 'tools-computer-use', + 'tools-creation', 'tools-git', 'tools-image-analysis', 'tools-mcp', @@ -1290,6 +1292,15 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'tools-miniapp must compose only MiniApp publication and runtime tool dependencies', }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'tools-creation', + requiredFeatureRefs: [ + 'bitfun-tool-packs/creation', + ], + exact: true, + reason: 'tools-creation must own only Creative product-authoring tools', + }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'tools-canvas', @@ -1657,6 +1668,7 @@ export const ownerCrateFeatureAssemblyRules = [ 'computer-use', 'image-analysis', 'miniapp', + 'creation', 'canvas', 'agent-control', ], diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index d2ab567a26..b6df17e251 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -234,6 +234,7 @@ export function prepareTauriConfig( // packaging injects it here; frontend:build-all (beforeBuildCommand) // compiles the profile before Tauri copies resources. injectDshProfileResource(config); + injectExternalFrontendResource(config); const release = releaseChannel ?? resolveReleaseChannel(process.env.BITFUN_RELEASE_CHANNEL); @@ -296,6 +297,8 @@ export function prepareTauriConfig( const DSH_PROFILE_RESOURCE_SOURCE = '../../../packages/dsh-acp/dist-profile'; const DSH_PROFILE_RESOURCE_TARGET = 'resources/dsh-profile'; +const EXTERNAL_FRONTEND_RESOURCE_SOURCE = '../../../dist'; +const EXTERNAL_FRONTEND_RESOURCE_TARGET = 'frontend/dist'; function injectDshProfileResource(config) { const resources = { ...(config.bundle?.resources || {}) }; @@ -306,6 +309,15 @@ function injectDshProfileResource(config) { }; } +function injectExternalFrontendResource(config) { + const resources = { ...(config.bundle?.resources || {}) }; + resources[EXTERNAL_FRONTEND_RESOURCE_SOURCE] = EXTERNAL_FRONTEND_RESOURCE_TARGET; + config.bundle = { + ...(config.bundle || {}), + resources, + }; +} + function injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary) { const resources = { ...(config.bundle?.resources || {}) }; delete resources['../../../resources/flashgrep']; diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 1dd4350ac6..b942903efc 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -344,6 +344,10 @@ test('official packaging injects the DeepSeek profile resource', () => { config.bundle.resources['../../../packages/dsh-acp/dist-profile'], 'resources/dsh-profile', ); + assert.equal( + config.bundle.resources['../../../dist'], + 'frontend/dist', + ); assert.equal( config.bundle.resources['resources/worker_host.js'], 'resources/worker_host.js', @@ -353,6 +357,16 @@ test('official packaging injects the DeepSeek profile resource', () => { } }); +test('static desktop configs keep the full frontend outside Tauri embedded assets', () => { + for (const name of ['tauri.conf.json', 'tauri.dev.conf.json']) { + const config = JSON.parse( + readFileSync(join(ROOT, 'src', 'apps', 'desktop', name), 'utf8') + ); + assert.equal(config.build.frontendDist, 'bootstrap-ui'); + assert.equal(config.bundle.resources['../../../dist'], undefined); + } +}); + test('Desktop release config bundles models.dev notices and provenance', () => { const config = JSON.parse( readFileSync(join(ROOT, 'src', 'apps', 'desktop', 'tauri.conf.json'), 'utf8') diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 91a1da0f1b..8b0fcfb067 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -10,6 +10,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "show_main_window", "hide_main_window_after_close_request", + "confirm_frontend_update", + "rollback_frontend_update", "quit_app", "minimize_to_tray", "initialize_tray_after_startup", @@ -276,6 +278,12 @@ mod tests { )); } + #[test] + fn frontend_update_decisions_stay_on_the_controller_device() { + assert!(is_local_only_command("confirm_frontend_update")); + assert!(is_local_only_command("rollback_frontend_update")); + } + #[test] fn product_control_presentation_callbacks_stay_on_the_controller_device() { for command in [ diff --git a/src/apps/desktop/bootstrap-ui/frontend-update-confirm.html b/src/apps/desktop/bootstrap-ui/frontend-update-confirm.html new file mode 100644 index 0000000000..a0352c0980 --- /dev/null +++ b/src/apps/desktop/bootstrap-ui/frontend-update-confirm.html @@ -0,0 +1,68 @@ + + + + + + Confirm frontend update + + + +
+

Keep this frontend?

+

Confirm within 15 seconds or BitFun will restore the previous version automatically.

+
+ + +
+ +
+ + + diff --git a/src/apps/desktop/bootstrap-ui/index.html b/src/apps/desktop/bootstrap-ui/index.html new file mode 100644 index 0000000000..362dffc6d1 --- /dev/null +++ b/src/apps/desktop/bootstrap-ui/index.html @@ -0,0 +1,21 @@ + + + + + + BitFun recovery + + + +
+

BitFun frontend recovery

+

The external frontend bundle could not be loaded. Restart BitFun to restore the packaged revision. / 外置前端无法加载,请重启 BitFun 以恢复打包版本。

+
+ + diff --git a/src/apps/desktop/capabilities/default.json b/src/apps/desktop/capabilities/default.json index 2ecc97db95..d559b42e8b 100644 --- a/src/apps/desktop/capabilities/default.json +++ b/src/apps/desktop/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "BitFun default capabilities", - "windows": ["main", "agent-companion-pet", "spotlight"], + "windows": ["main", "agent-companion-pet", "spotlight", "frontend-update-confirm"], "permissions": [ "log:default", "autostart:default", diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index 5a53026cfd..0247b7f8d3 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -32,6 +32,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ // Window / tray / process chrome "show_main_window", "hide_main_window_after_close_request", + "confirm_frontend_update", + "rollback_frontend_update", "quit_app", "minimize_to_tray", "initialize_tray_after_startup", @@ -668,6 +670,12 @@ mod tests { )); } + #[test] + fn frontend_update_decisions_stay_with_the_controller_window() { + assert!(is_local_only_command("confirm_frontend_update")); + assert!(is_local_only_command("rollback_frontend_update")); + } + #[test] fn product_control_presentation_callbacks_stay_with_the_controller_window() { for command in [ diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 0c529d4951..32398ec5a2 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -53,6 +53,8 @@ pub enum RemoteWorkspacePolicy { /// Declared remote-workspace policy for every registered Tauri command. pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = &[ + ("confirm_frontend_update", RemoteWorkspacePolicy::LocalOnly), + ("rollback_frontend_update", RemoteWorkspacePolicy::LocalOnly), ("accept_file", RemoteWorkspacePolicy::LegacyUnaudited), ("accept_operation", RemoteWorkspacePolicy::LegacyUnaudited), ("accept_session", RemoteWorkspacePolicy::LegacyUnaudited), @@ -2061,6 +2063,17 @@ mod tests { ); } + #[test] + fn frontend_update_decisions_are_local_desktop_only() { + for command in ["confirm_frontend_update", "rollback_frontend_update"] { + assert_eq!( + remote_workspace_policy(command), + Some(RemoteWorkspacePolicy::LocalOnly), + "{command} must stay with the immutable controller-side confirmation window" + ); + } + } + #[test] fn external_mcp_import_commands_explicitly_reject_remote_workspaces() { for command in [ diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index 55f4e32270..6d6631a614 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -581,7 +581,7 @@ pub fn create_main_window( } } } else { - WebviewUrl::App("index.html".into()) + crate::frontend_workbench::custom_frontend_url("index.html") }; let main_url_kind = match &main_url { WebviewUrl::External(_) => "external", @@ -623,10 +623,9 @@ pub fn create_main_window( // Keep HTML5 drag-and-drop working inside the webview for desktop UI drag targets. builder = builder.disable_drag_drop_handler(); - // Debug builds may reload the initial page for development. Release builds - // keep top-level reloads blocked. Both modes continue to reject navigation - // to a different main-page URL and allow local sandboxed MiniApp documents. - let navigation_policy = MainWebviewNavigationPolicy::new(cfg!(debug_assertions)); + // Exact top-level reloads are required for provisional Creative frontend + // activation and rollback. Different main-page URLs remain blocked. + let navigation_policy = MainWebviewNavigationPolicy::new(true); builder = builder.on_navigation(move |url| navigation_policy.should_allow(url)); #[cfg(target_os = "macos")] @@ -746,12 +745,7 @@ fn app_url(path: &str) -> WebviewUrl { } } } else { - let app_path = if path.starts_with('?') { - format!("index.html{}", path) - } else { - path.to_string() - }; - WebviewUrl::App(app_path.into()) + crate::frontend_workbench::custom_frontend_url(path) } } diff --git a/src/apps/desktop/src/frontend_workbench.rs b/src/apps/desktop/src/frontend_workbench.rs new file mode 100644 index 0000000000..e4fea1dd77 --- /dev/null +++ b/src/apps/desktop/src/frontend_workbench.rs @@ -0,0 +1,939 @@ +//! Writable packaged-frontend revisions with crash-safe provisional activation. + +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use bitfun_core::agentic::tools::frontend_workbench_host::{ + set_frontend_workbench_handler, FrontendWorkbenchHostRequest, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tauri::{Manager, Url, WebviewUrl, WebviewWindowBuilder}; +use uuid::Uuid; + +pub const FRONTEND_PROTOCOL_SCHEME: &str = "bitfun-ui"; +pub const FRONTEND_URL: &str = "bitfun-ui://localhost/index.html"; +const CONFIRM_WINDOW_LABEL: &str = "frontend-update-confirm"; +const CONFIRM_TIMEOUT: Duration = Duration::from_secs(15); +const STATE_SCHEMA_VERSION: u32 = 1; +const RECOVERY_HTML: &[u8] = include_bytes!("../bootstrap-ui/index.html"); + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct FrontendWorkbenchState { + schema_version: u32, + bundled_revision: Option, + active_revision: Option, + previous_revision: Option, + pending: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct PendingFrontendRevision { + transaction_id: String, + revision_id: String, + previous_revision: String, + expires_at_unix_ms: u64, +} + +impl Default for PendingFrontendRevision { + fn default() -> Self { + Self { + transaction_id: String::new(), + revision_id: String::new(), + previous_revision: String::new(), + expires_at_unix_ms: 0, + } + } +} + +pub struct FrontendWorkbenchManager { + root: PathBuf, + state: Mutex, + app: OnceLock, +} + +impl FrontendWorkbenchManager { + pub fn new(user_data_dir: &Path) -> Self { + Self { + root: user_data_dir.join("frontend-workbench"), + state: Mutex::new(FrontendWorkbenchState::default()), + app: OnceLock::new(), + } + } + + fn revisions_dir(&self) -> PathBuf { + self.root.join("revisions") + } + + fn drafts_dir(&self) -> PathBuf { + self.root.join("drafts") + } + + fn state_path(&self) -> PathBuf { + self.root.join("state.json") + } + + fn revision_dir(&self, revision_id: &str) -> PathBuf { + self.revisions_dir().join(revision_id) + } + + pub fn initialize( + self: &Arc, + app: &tauri::AppHandle, + bundled_frontend: &Path, + ) -> Result<(), String> { + validate_frontend_tree(bundled_frontend)?; + fs::create_dir_all(self.revisions_dir()).map_err(io_error("create revision directory"))?; + fs::create_dir_all(self.drafts_dir()).map_err(io_error("create draft directory"))?; + + let mut state = self.load_state(); + state.schema_version = STATE_SCHEMA_VERSION; + + // A pending candidate can never survive a process exit: startup restores + // the prior revision before any editable content is served. + if let Some(pending) = state.pending.take() { + if self.revision_is_available(&pending.previous_revision) { + state.active_revision = Some(pending.previous_revision.clone()); + state.previous_revision = None; + log::warn!( + "Recovered an unconfirmed frontend update by rolling back: transaction_id={}", + pending.transaction_id + ); + } else { + // Never keep serving an unconfirmed candidate just because its + // prior revision was damaged outside BitFun. The bundled copy + // below becomes the recovery target. + state.active_revision = None; + state.previous_revision = None; + log::warn!( + "Discarded an unconfirmed frontend update whose previous revision is unavailable: transaction_id={}", + pending.transaction_id + ); + } + } + + let bundled_revision = bundled_revision_id(bundled_frontend)?; + let bundled_destination = self.revision_dir(&bundled_revision); + if !bundled_destination.join("index.html").is_file() { + copy_tree_transactional(bundled_frontend, &bundled_destination)?; + } + + let bundled_changed = state.bundled_revision.as_deref() != Some(&bundled_revision); + let active_is_valid = state + .active_revision + .as_deref() + .is_some_and(|revision| self.revision_is_available(revision)); + if bundled_changed { + state.previous_revision = state.active_revision.filter(|_| active_is_valid); + state.active_revision = Some(bundled_revision.clone()); + state.bundled_revision = Some(bundled_revision); + } else if !active_is_valid { + state.active_revision = Some(bundled_revision.clone()); + state.bundled_revision = Some(bundled_revision); + state.previous_revision = None; + } + + self.save_state(&state)?; + *self.state.lock().map_err(lock_error)? = state; + let _ = self.app.set(app.clone()); + self.install_tool_host(); + Ok(()) + } + + fn install_tool_host(self: &Arc) { + let manager = Arc::clone(self); + set_frontend_workbench_handler(Arc::new(move |request| { + let manager = Arc::clone(&manager); + Box::pin(async move { manager.handle_tool_request(request) }) + })); + } + + fn handle_tool_request( + self: &Arc, + request: FrontendWorkbenchHostRequest, + ) -> Result { + ensure_local_confirmation_surface( + crate::api::peer_host_invoke::attached_controllers().len(), + )?; + match request.action.as_str() { + "prepare" => self.prepare(), + "status" => self.status(), + "apply" => self.apply( + request + .draft_id + .as_deref() + .ok_or_else(|| "draft_id is required for apply".to_string())?, + ), + "rollback" => self.rollback_confirmed(), + other => Err(format!("Unsupported FrontendWorkbench action: {other}")), + } + } + + fn prepare(&self) -> Result { + let active_revision = self + .state + .lock() + .map_err(lock_error)? + .active_revision + .clone() + .ok_or_else(|| "No active frontend revision is available".to_string())?; + validate_revision_id(&active_revision)?; + let source = self.revision_dir(&active_revision); + validate_frontend_tree(&source)?; + + let draft_id = Uuid::new_v4().to_string(); + let draft_path = self.drafts_dir().join(&draft_id); + copy_tree_transactional(&source, &draft_path)?; + fs::write( + draft_path.join("CREATION.md"), + format!( + "# BitFun frontend draft\n\nDraft id: `{draft_id}`\nBase revision: `{active_revision}`\n\nEdit only this directory. Preserve `index.html`. Prefer `bitfun-creation.css` and `bitfun-creation.js` for isolated overrides. Apply with `FrontendWorkbench` and this exact draft id; the user must confirm within 15 seconds.\n" + ), + ) + .map_err(io_error("write draft instructions"))?; + + Ok(json!({ + "status": "prepared", + "draftId": draft_id, + "draftPath": draft_path.to_string_lossy(), + "baseRevision": active_revision, + })) + } + + fn apply(self: &Arc, draft_id: &str) -> Result { + validate_uuid(draft_id, "draft_id")?; + let draft_path = self.drafts_dir().join(draft_id); + validate_frontend_tree(&draft_path)?; + + let revision_id = format!("creative-{}", Uuid::new_v4()); + copy_tree_transactional(&draft_path, &self.revision_dir(&revision_id))?; + let transaction_id = Uuid::new_v4().to_string(); + let expires_at_unix_ms = unix_ms().saturating_add(CONFIRM_TIMEOUT.as_millis() as u64); + + { + let mut state = self.state.lock().map_err(lock_error)?; + if state.pending.is_some() { + return Err( + "Another frontend update is awaiting confirmation; confirm or roll it back first" + .to_string(), + ); + } + let previous_revision = state + .active_revision + .clone() + .ok_or_else(|| "No active frontend revision is available".to_string())?; + let mut next = state.clone(); + next.previous_revision = Some(previous_revision.clone()); + next.active_revision = Some(revision_id.clone()); + next.pending = Some(PendingFrontendRevision { + transaction_id: transaction_id.clone(), + revision_id: revision_id.clone(), + previous_revision, + expires_at_unix_ms, + }); + self.save_state(&next)?; + *state = next; + } + + // Arm the host-owned timeout before touching either webview. Even if a + // navigation or confirmation-window operation fails, the candidate can + // never remain active indefinitely. + let manager = Arc::clone(self); + let timer_transaction_id = transaction_id.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(CONFIRM_TIMEOUT).await; + if let Err(error) = manager.rollback_pending(&timer_transaction_id, "timeout") { + log::warn!( + "Failed to auto-rollback provisional frontend update: transaction_id={}, error={}", + timer_transaction_id, + error + ); + } + }); + + let activation_result = self + .app + .get() + .cloned() + .ok_or_else(|| "Frontend workbench desktop host is not initialized".to_string()) + .and_then(|app| { + navigate_main_to_frontend(&app)?; + show_confirmation_window(&app, &transaction_id) + }); + if let Err(activation_error) = activation_result { + return match self.rollback_pending(&transaction_id, "activation_error") { + Ok(_) => Err(activation_error), + Err(rollback_error) => Err(format!( + "{activation_error}; the immediate rollback also failed: {rollback_error}" + )), + }; + } + + Ok(json!({ + "status": "pending_confirmation", + "revisionId": revision_id, + "transactionId": transaction_id, + "expiresAtUnixMs": expires_at_unix_ms, + "confirmationTimeoutSeconds": CONFIRM_TIMEOUT.as_secs(), + })) + } + + pub fn confirm_pending(&self, transaction_id: &str) -> Result { + let expired = { + let state = self.state.lock().map_err(lock_error)?; + let pending = state + .pending + .as_ref() + .ok_or_else(|| "No frontend update is awaiting confirmation".to_string())?; + if pending.transaction_id != transaction_id { + return Err("The frontend confirmation transaction is stale".to_string()); + } + unix_ms() >= pending.expires_at_unix_ms + }; + if expired { + self.rollback_pending(transaction_id, "expired_confirmation")?; + return Err("The 15-second frontend confirmation window has expired".to_string()); + } + + let revision_id = { + let mut state = self.state.lock().map_err(lock_error)?; + let pending = state + .pending + .as_ref() + .ok_or_else(|| "No frontend update is awaiting confirmation".to_string())?; + if pending.transaction_id != transaction_id { + return Err("The frontend confirmation transaction is stale".to_string()); + } + let revision_id = pending.revision_id.clone(); + let mut next = state.clone(); + next.pending = None; + self.save_state(&next)?; + *state = next; + revision_id + }; + self.close_confirmation_window(); + Ok(json!({"status": "confirmed", "revisionId": revision_id})) + } + + pub fn rollback_pending(&self, transaction_id: &str, reason: &str) -> Result { + let restored_revision = { + let mut state = self.state.lock().map_err(lock_error)?; + let Some(pending) = state.pending.as_ref() else { + return Ok(self.status_value(&state)); + }; + if pending.transaction_id != transaction_id { + return Ok(self.status_value(&state)); + } + let restored_revision = pending.previous_revision.clone(); + if !self.revision_is_available(&restored_revision) { + return Err("The previous frontend revision is unavailable".to_string()); + } + let mut next = state.clone(); + next.active_revision = Some(restored_revision.clone()); + next.previous_revision = None; + next.pending = None; + self.save_state(&next)?; + *state = next; + restored_revision + }; + self.close_confirmation_window(); + if let Some(app) = self.app.get() { + navigate_main_to_frontend(app)?; + } + log::info!( + "Frontend revision rolled back: reason={}, restored_revision={}", + reason, + restored_revision + ); + Ok(json!({"status": "rolled_back", "activeRevision": restored_revision, "reason": reason})) + } + + fn rollback_confirmed(&self) -> Result { + let pending_transaction = self + .state + .lock() + .map_err(lock_error)? + .pending + .as_ref() + .map(|pending| pending.transaction_id.clone()); + if let Some(transaction_id) = pending_transaction { + return self.rollback_pending(&transaction_id, "explicit"); + } + + let restored_revision = { + let mut state = self.state.lock().map_err(lock_error)?; + let target = state.previous_revision.clone().ok_or_else(|| { + "No previous confirmed frontend revision is available".to_string() + })?; + if !self.revision_is_available(&target) { + return Err("The previous frontend revision is unavailable".to_string()); + } + let mut next = state.clone(); + let current = next.active_revision.replace(target.clone()); + next.previous_revision = current; + self.save_state(&next)?; + *state = next; + target + }; + if let Some(app) = self.app.get() { + navigate_main_to_frontend(app)?; + } + Ok(json!({"status": "rolled_back", "activeRevision": restored_revision})) + } + + fn status(&self) -> Result { + let state = self.state.lock().map_err(lock_error)?; + Ok(self.status_value(&state)) + } + + fn status_value(&self, state: &FrontendWorkbenchState) -> Value { + json!({ + "status": if state.pending.is_some() { "pending_confirmation" } else { "ready" }, + "activeRevision": state.active_revision, + "bundledRevision": state.bundled_revision, + "previousRevision": state.previous_revision, + "pending": state.pending, + "confirmationTimeoutSeconds": CONFIRM_TIMEOUT.as_secs(), + }) + } + + pub fn protocol_response( + &self, + request: tauri::http::Request>, + ) -> tauri::http::Response> { + let request_path = request.uri().path(); + match self.read_protocol_asset(request_path) { + Ok((bytes, content_type)) => tauri::http::Response::builder() + .status(tauri::http::StatusCode::OK) + .header(tauri::http::header::CONTENT_TYPE, content_type) + .header(tauri::http::header::CACHE_CONTROL, "no-store, max-age=0") + .header(tauri::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .body(bytes) + .unwrap_or_else(|_| tauri::http::Response::new(Vec::new())), + Err(error) if matches!(request_path, "" | "/" | "/index.html") => { + log::error!("Serving immutable frontend recovery page: error={error}"); + tauri::http::Response::builder() + .status(tauri::http::StatusCode::SERVICE_UNAVAILABLE) + .header( + tauri::http::header::CONTENT_TYPE, + "text/html; charset=utf-8", + ) + .header(tauri::http::header::CACHE_CONTROL, "no-store, max-age=0") + .body(RECOVERY_HTML.to_vec()) + .unwrap_or_else(|_| tauri::http::Response::new(Vec::new())) + } + Err(error) => tauri::http::Response::builder() + .status(tauri::http::StatusCode::NOT_FOUND) + .header( + tauri::http::header::CONTENT_TYPE, + "text/plain; charset=utf-8", + ) + .header(tauri::http::header::CACHE_CONTROL, "no-store, max-age=0") + .body(error.into_bytes()) + .unwrap_or_else(|_| tauri::http::Response::new(Vec::new())), + } + } + + fn read_protocol_asset(&self, request_path: &str) -> Result<(Vec, &'static str), String> { + let decoded = urlencoding::decode(request_path) + .map_err(|_| "Invalid frontend asset path encoding".to_string())?; + let relative = decoded.trim_start_matches('/'); + let relative = if relative.is_empty() { + "index.html" + } else { + relative + }; + let relative_path = Path::new(relative); + if relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err("Invalid frontend asset path".to_string()); + } + + let active_revision = self + .state + .lock() + .map_err(lock_error)? + .active_revision + .clone() + .ok_or_else(|| "Frontend workbench is not initialized".to_string())?; + validate_revision_id(&active_revision)?; + let root = self.revision_dir(&active_revision); + let canonical_root = root + .canonicalize() + .map_err(|error| format!("Frontend root is unavailable: {error}"))?; + let mut candidate = root.join(relative_path); + if candidate.is_dir() { + candidate = candidate.join("index.html"); + } + if !candidate.is_file() && relative_path.extension().is_none() { + candidate = root.join("index.html"); + } + let canonical_candidate = candidate + .canonicalize() + .map_err(|_| format!("Frontend asset not found: {relative}"))?; + if !canonical_candidate.starts_with(&canonical_root) || !canonical_candidate.is_file() { + return Err("Frontend asset path escaped the active revision".to_string()); + } + let content_type = content_type_for(&canonical_candidate); + fs::read(&canonical_candidate) + .map(|bytes| (bytes, content_type)) + .map_err(|error| format!("Failed to read frontend asset: {error}")) + } + + fn load_state(&self) -> FrontendWorkbenchState { + let path = self.state_path(); + let Ok(bytes) = fs::read(&path) else { + return FrontendWorkbenchState::default(); + }; + match serde_json::from_slice(&bytes) { + Ok(state) => state, + Err(error) => { + let preserved = self.root.join(format!("state.invalid.{}.json", unix_ms())); + if let Err(copy_error) = fs::copy(&path, &preserved) { + log::warn!( + "Failed to preserve unreadable frontend state: source={}, destination={}, error={}", + path.display(), + preserved.display(), + copy_error + ); + } + log::warn!( + "Ignoring unreadable frontend workbench state after preserving it: path={}, error={}", + path.display(), + error + ); + FrontendWorkbenchState::default() + } + } + } + + fn save_state(&self, state: &FrontendWorkbenchState) -> Result<(), String> { + fs::create_dir_all(&self.root).map_err(io_error("create frontend workbench root"))?; + let bytes = serde_json::to_vec_pretty(state) + .map_err(|error| format!("Failed to serialize frontend workbench state: {error}"))?; + let temporary = self.root.join(format!("state.{}.tmp", Uuid::new_v4())); + fs::write(&temporary, bytes).map_err(io_error("write frontend workbench state"))?; + match fs::rename(&temporary, self.state_path()) { + Ok(()) => Ok(()), + Err(_error) if self.state_path().exists() => { + let backup = self.root.join("state.previous.json"); + let _ = fs::copy(self.state_path(), backup); + fs::remove_file(self.state_path()) + .map_err(io_error("replace frontend workbench state"))?; + fs::rename(&temporary, self.state_path()) + .map_err(io_error("commit frontend workbench state")) + } + Err(error) => Err(format!( + "Failed to commit frontend workbench state: {error}" + )), + } + } + + fn close_confirmation_window(&self) { + if let Some(window) = self + .app + .get() + .and_then(|app| app.get_webview_window(CONFIRM_WINDOW_LABEL)) + { + let _ = window.close(); + } + } + + fn revision_is_available(&self, revision_id: &str) -> bool { + validate_revision_id(revision_id).is_ok() + && self.revision_dir(revision_id).join("index.html").is_file() + } +} + +fn ensure_local_confirmation_surface(attached_peer_controllers: usize) -> Result<(), String> { + if attached_peer_controllers > 0 { + return Err( + "FrontendWorkbench is unavailable while this BitFun host is controlled through Peer Device Mode; run Creative mode on the visible local desktop instead" + .to_string(), + ); + } + Ok(()) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrontendUpdateDecisionRequest { + transaction_id: String, +} + +#[tauri::command] +pub async fn confirm_frontend_update( + state: tauri::State<'_, Arc>, + webview: tauri::WebviewWindow, + request: FrontendUpdateDecisionRequest, +) -> Result { + require_confirmation_window(&webview)?; + state.confirm_pending(&request.transaction_id) +} + +#[tauri::command] +pub async fn rollback_frontend_update( + state: tauri::State<'_, Arc>, + webview: tauri::WebviewWindow, + request: FrontendUpdateDecisionRequest, +) -> Result { + require_confirmation_window(&webview)?; + state.rollback_pending(&request.transaction_id, "user") +} + +fn require_confirmation_window(webview: &tauri::WebviewWindow) -> Result<(), String> { + if webview.label() != CONFIRM_WINDOW_LABEL { + return Err( + "Frontend updates can only be confirmed from the immutable confirmation window" + .to_string(), + ); + } + Ok(()) +} + +pub fn custom_frontend_url(path: &str) -> WebviewUrl { + let suffix = if path.is_empty() { + "index.html".to_string() + } else if path.starts_with('?') { + format!("index.html{path}") + } else { + path.trim_start_matches('/').to_string() + }; + let url = format!("{FRONTEND_PROTOCOL_SCHEME}://localhost/{suffix}") + .parse::() + .expect("static frontend custom-protocol URL must parse"); + WebviewUrl::CustomProtocol(url) +} + +fn show_confirmation_window(app: &tauri::AppHandle, transaction_id: &str) -> Result<(), String> { + if let Some(window) = app.get_webview_window(CONFIRM_WINDOW_LABEL) { + let _ = window.close(); + } + let url = WebviewUrl::App( + format!( + "frontend-update-confirm.html?transactionId={}", + urlencoding::encode(transaction_id) + ) + .into(), + ); + WebviewWindowBuilder::new(app, CONFIRM_WINDOW_LABEL, url) + .title("Confirm BitFun frontend update") + .inner_size(420.0, 260.0) + .resizable(false) + .always_on_top(true) + .center() + .focused(true) + .build() + .map(|_| ()) + .map_err(|error| format!("Failed to open frontend confirmation window: {error}")) +} + +fn navigate_main_to_frontend(app: &tauri::AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or_else(|| "Main window is unavailable".to_string())?; + let url = FRONTEND_URL + .parse::() + .map_err(|error| format!("Invalid frontend URL: {error}"))?; + window + .navigate(url) + .map_err(|error| format!("Failed to reload the active frontend revision: {error}")) +} + +fn bundled_revision_id(root: &Path) -> Result { + let mut files = Vec::new(); + visit_tree(root, &mut |path, metadata| { + if metadata.is_file() { + files.push(path.to_path_buf()); + } + Ok(()) + })?; + files.sort(); + + let mut hasher = Sha256::new(); + hasher.update(env!("CARGO_PKG_VERSION").as_bytes()); + hasher.update([0]); + for path in files { + let relative = path + .strip_prefix(root) + .map_err(|_| "Bundled frontend asset escaped its root".to_string())? + .to_string_lossy() + .replace('\\', "/"); + let bytes = fs::read(&path).map_err(io_error("read bundled frontend asset"))?; + hasher.update((relative.len() as u64).to_le_bytes()); + hasher.update(relative.as_bytes()); + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + } + let digest = format!("{:x}", hasher.finalize()); + Ok(format!("bundled-{}", &digest[..16])) +} + +fn validate_frontend_tree(root: &Path) -> Result<(), String> { + if !root.is_dir() { + return Err(format!( + "Frontend directory is unavailable: {}", + root.display() + )); + } + if !root.join("index.html").is_file() { + return Err(format!( + "Frontend directory has no index.html: {}", + root.display() + )); + } + visit_tree(root, &mut |path, metadata| { + if metadata.file_type().is_symlink() { + return Err(format!( + "Frontend revisions cannot contain symbolic links: {}", + path.display() + )); + } + Ok(()) + }) +} + +fn copy_tree_transactional(source: &Path, destination: &Path) -> Result<(), String> { + if destination.exists() { + return Err(format!( + "Frontend destination already exists: {}", + destination.display() + )); + } + validate_frontend_tree(source)?; + let parent = destination + .parent() + .ok_or_else(|| "Frontend destination has no parent".to_string())?; + fs::create_dir_all(parent).map_err(io_error("create frontend destination parent"))?; + let staging = parent.join(format!(".copy-{}", Uuid::new_v4())); + fs::create_dir(&staging).map_err(io_error("create frontend copy staging directory"))?; + let result = copy_tree_contents(source, &staging) + .and_then(|_| fs::rename(&staging, destination).map_err(io_error("commit frontend copy"))); + if result.is_err() { + let _ = fs::remove_dir_all(&staging); + } + result +} + +fn copy_tree_contents(source: &Path, destination: &Path) -> Result<(), String> { + for entry in fs::read_dir(source).map_err(io_error("read frontend directory"))? { + let entry = entry.map_err(io_error("read frontend directory entry"))?; + let source_path = entry.path(); + let metadata = fs::symlink_metadata(&source_path) + .map_err(io_error("inspect frontend directory entry"))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Frontend revisions cannot contain symbolic links: {}", + source_path.display() + )); + } + let destination_path = destination.join(entry.file_name()); + if metadata.is_dir() { + fs::create_dir(&destination_path).map_err(io_error("create frontend subdirectory"))?; + copy_tree_contents(&source_path, &destination_path)?; + } else if metadata.is_file() { + fs::copy(&source_path, &destination_path).map_err(io_error("copy frontend asset"))?; + } + } + Ok(()) +} + +fn visit_tree( + root: &Path, + visitor: &mut impl FnMut(&Path, &fs::Metadata) -> Result<(), String>, +) -> Result<(), String> { + for entry in fs::read_dir(root).map_err(io_error("read frontend tree"))? { + let entry = entry.map_err(io_error("read frontend tree entry"))?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(io_error("inspect frontend tree"))?; + visitor(&path, &metadata)?; + if metadata.is_dir() { + visit_tree(&path, visitor)?; + } + } + Ok(()) +} + +fn validate_uuid(value: &str, field: &str) -> Result<(), String> { + let parsed = Uuid::parse_str(value).map_err(|_| format!("{field} is invalid"))?; + if parsed.to_string() != value.to_ascii_lowercase() { + return Err(format!("{field} is invalid")); + } + Ok(()) +} + +fn validate_revision_id(value: &str) -> Result<(), String> { + let mut components = Path::new(value).components(); + if value.is_empty() + || !matches!(components.next(), Some(Component::Normal(_))) + || components.next().is_some() + { + return Err("Frontend revision id is invalid".to_string()); + } + Ok(()) +} + +fn content_type_for(path: &Path) -> &'static str { + match path.extension().and_then(|extension| extension.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("js" | "mjs") => "text/javascript; charset=utf-8", + Some("json" | "map") => "application/json; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("ico") => "image/x-icon", + Some("woff") => "font/woff", + Some("woff2") => "font/woff2", + Some("ttf") => "font/ttf", + Some("wasm") => "application/wasm", + _ => "application/octet-stream", + } +} + +fn unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn io_error(operation: &'static str) -> impl FnOnce(std::io::Error) -> String { + move |error| format!("Failed to {operation}: {error}") +} + +fn lock_error(error: std::sync::PoisonError) -> String { + format!("Frontend workbench state lock is unavailable: {error}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_frontend(root: &Path, label: &str) { + fs::create_dir_all(root.join("assets")).expect("asset directory"); + fs::write(root.join("index.html"), format!("

{label}

")).expect("index.html"); + fs::write(root.join("assets/app.js"), "export {};").expect("asset"); + } + + #[test] + fn transactional_copy_preserves_a_valid_frontend() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + write_frontend(&source, "source"); + copy_tree_transactional(&source, &destination).expect("copy"); + assert_eq!( + fs::read_to_string(destination.join("index.html")).expect("copied index"), + "

source

" + ); + } + + #[test] + fn bundled_revision_fingerprint_covers_non_index_assets() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + write_frontend(&source, "source"); + let first = bundled_revision_id(&source).expect("first fingerprint"); + + fs::write(source.join("assets/app.js"), "export const changed = true;") + .expect("change asset"); + let second = bundled_revision_id(&source).expect("second fingerprint"); + + assert_ne!(first, second); + } + + #[cfg(unix)] + #[test] + fn validation_rejects_symlinked_assets() { + use std::os::unix::fs::symlink; + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + write_frontend(&source, "source"); + symlink(source.join("index.html"), source.join("linked.html")).expect("symlink"); + assert!(validate_frontend_tree(&source) + .expect_err("symlink should fail") + .contains("symbolic links")); + } + + #[test] + fn revision_ids_cannot_escape_the_revision_root() { + for value in ["", ".", "..", "../outside", "/outside"] { + assert!(validate_revision_id(value).is_err(), "accepted {value}"); + } + assert!(validate_revision_id("bundled-0123456789abcdef").is_ok()); + assert!(validate_revision_id("creative-38e14f63-30ad-4ad7-9e4e-5ad556450ba3").is_ok()); + } + + #[test] + fn peer_control_requires_a_visible_local_confirmation_surface() { + assert!(ensure_local_confirmation_surface(0).is_ok()); + let error = ensure_local_confirmation_surface(1) + .expect_err("peer-controlled frontend updates must fail loudly"); + assert!(error.contains("Peer Device Mode")); + } + + #[test] + fn legacy_state_fields_default_without_data_loss() { + let state: FrontendWorkbenchState = + serde_json::from_str(r#"{"activeRevision":"bundled-old","unknownFutureField":true}"#) + .expect("legacy state"); + assert_eq!(state.active_revision.as_deref(), Some("bundled-old")); + assert!(state.pending.is_none()); + } + + #[test] + fn expired_confirmation_restores_the_previous_revision() { + let temp = tempfile::tempdir().expect("tempdir"); + let manager = FrontendWorkbenchManager::new(temp.path()); + write_frontend(&manager.revision_dir("previous"), "previous"); + write_frontend(&manager.revision_dir("candidate"), "candidate"); + *manager.state.lock().expect("state") = FrontendWorkbenchState { + schema_version: STATE_SCHEMA_VERSION, + bundled_revision: Some("previous".to_string()), + active_revision: Some("candidate".to_string()), + previous_revision: Some("previous".to_string()), + pending: Some(PendingFrontendRevision { + transaction_id: "expired-transaction".to_string(), + revision_id: "candidate".to_string(), + previous_revision: "previous".to_string(), + expires_at_unix_ms: unix_ms().saturating_sub(1), + }), + }; + + let error = manager + .confirm_pending("expired-transaction") + .expect_err("expired confirmation must fail"); + assert!(error.contains("expired")); + let state = manager.state.lock().expect("state"); + assert_eq!(state.active_revision.as_deref(), Some("previous")); + assert!(state.pending.is_none()); + } + + #[test] + fn protocol_uses_immutable_recovery_page_when_no_revision_is_ready() { + let temp = tempfile::tempdir().expect("tempdir"); + let manager = FrontendWorkbenchManager::new(temp.path()); + let request = tauri::http::Request::builder() + .uri("bitfun-ui://localhost/index.html") + .body(Vec::new()) + .expect("request"); + + let response = manager.protocol_response(request); + + assert_eq!( + response.status(), + tauri::http::StatusCode::SERVICE_UNAVAILABLE + ); + assert!(String::from_utf8_lossy(response.body()).contains("BitFun frontend recovery")); + } +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 660a2740ad..da8082f1cc 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -27,6 +27,7 @@ mod builtin_browser_host; pub mod computer_use; pub mod crash_diagnostics; mod embedded_relay_host; +pub mod frontend_workbench; pub mod logging; pub mod macos_menubar; pub mod runtime; @@ -49,6 +50,7 @@ use bitfun_core::util::{elapsed_ms, TimingCollector}; use bitfun_events::AgenticEvent; use bitfun_transport::{TauriTransportAdapter, TransportAdapter}; use serde::Deserialize; +use std::path::PathBuf; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, OnceLock, @@ -810,8 +812,16 @@ pub async fn run() { let terminal_state = api::terminal_api::TerminalState::new(); let path_manager = get_path_manager_arc(); + let frontend_workbench = Arc::new(frontend_workbench::FrontendWorkbenchManager::new( + &path_manager.user_data_dir(), + )); let mut builder = tauri::Builder::default(); + let frontend_protocol_manager = Arc::clone(&frontend_workbench); + builder = builder.register_uri_scheme_protocol( + frontend_workbench::FRONTEND_PROTOCOL_SCHEME, + move |_context, request| frontend_protocol_manager.protocol_response(request), + ); #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] { @@ -864,6 +874,7 @@ pub async fn run() { .manage(coordinator) .manage(scheduler) .manage(terminal_state) + .manage(Arc::clone(&frontend_workbench)) .manage(startup_trace.clone()) .on_page_load(|webview, payload| { let label = webview.label(); @@ -917,6 +928,36 @@ pub async fn run() { ); startup_trace.record_logging_ready_and_stop_persistence(); + let bundled_frontend = if cfg!(debug_assertions) { + let development_dist = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .join("dist"); + development_dist + .join("index.html") + .is_file() + .then_some(development_dist) + } else { + app.path() + .resolve("frontend/dist", tauri::path::BaseDirectory::Resource) + .ok() + .filter(|path| path.join("index.html").is_file()) + }; + if let Some(bundled_frontend) = bundled_frontend { + if let Err(error) = + frontend_workbench.initialize(app.handle(), &bundled_frontend) + { + log::error!( + "Failed to initialize the external frontend workbench: path={}, error={}", + bundled_frontend.display(), + error + ); + } + } else { + log::warn!( + "External frontend bundle is unavailable; Creative frontend editing will remain disabled" + ); + } + // Ensure the Tauri NSIS registry install-location key points to the // actual install directory, so that auto-updates respect the custom // install path chosen during initial installation. @@ -1353,6 +1394,8 @@ pub async fn run() { }) .invoke_handler(tauri::generate_handler![ appearance::show_main_window, + frontend_workbench::confirm_frontend_update, + frontend_workbench::rollback_frontend_update, hide_main_window_after_close_request, api::agentic_api::create_session, api::agentic_api::update_session_mode, diff --git a/src/apps/desktop/tauri.conf.json b/src/apps/desktop/tauri.conf.json index 8ec3c4515d..0a4c19a43e 100644 --- a/src/apps/desktop/tauri.conf.json +++ b/src/apps/desktop/tauri.conf.json @@ -6,7 +6,7 @@ "beforeDevCommand": "pnpm run dev:web", "devUrl": "http://localhost:1422", "beforeBuildCommand": "pnpm run frontend:build-all", - "frontendDist": "../../../dist" + "frontendDist": "bootstrap-ui" }, "bundle": { "active": true, diff --git a/src/apps/desktop/tauri.dev.conf.json b/src/apps/desktop/tauri.dev.conf.json index c7959fbf1d..a14b754cfe 100644 --- a/src/apps/desktop/tauri.dev.conf.json +++ b/src/apps/desktop/tauri.dev.conf.json @@ -6,7 +6,7 @@ "beforeDevCommand": "pnpm run dev:web", "devUrl": "http://localhost:1422", "beforeBuildCommand": "pnpm run frontend:build-all", - "frontendDist": "../../../dist" + "frontendDist": "bootstrap-ui" }, "bundle": { "active": true, diff --git a/src/crates/assembly/agent-content/prompts/agents/creative_mode_first_entry_reminder.md b/src/crates/assembly/agent-content/prompts/agents/creative_mode_first_entry_reminder.md new file mode 100644 index 0000000000..150d182423 --- /dev/null +++ b/src/crates/assembly/agent-content/prompts/agents/creative_mode_first_entry_reminder.md @@ -0,0 +1,6 @@ +You have entered Creative mode. Product-creation capabilities are intentionally isolated here. + +- For MiniApps, load the `miniapp-dev` skill before editing. Start with `InitMiniApp`, edit only the returned app directory, then run `FinalizeMiniApp`. Call `PublishMiniApp` only when the user explicitly asks to submit or publish. +- For the BitFun client frontend, load the `bitfun-frontend-dev` skill first. Call `FrontendWorkbench` with `prepare`, edit only the returned draft directory, then call `apply` with its draft id. +- An applied frontend revision is provisional for 15 seconds. Never claim it was kept until the user presses the native confirmation button and `FrontendWorkbench status` reports no pending revision. If confirmation does not arrive, BitFun rolls back automatically. +- Frontend customization is local-desktop-only. Never substitute a controller-local path for a remote workspace. diff --git a/src/crates/assembly/agent-content/tests/prompt_catalog_contracts.rs b/src/crates/assembly/agent-content/tests/prompt_catalog_contracts.rs index 7e1431efbd..16acf1814c 100644 --- a/src/crates/assembly/agent-content/tests/prompt_catalog_contracts.rs +++ b/src/crates/assembly/agent-content/tests/prompt_catalog_contracts.rs @@ -33,6 +33,10 @@ const CATALOG_PROMPT_SOURCES: &[(&str, &[u8])] = &[ "cowork_mode", include_bytes!("../prompts/agents/cowork_mode.md"), ), + ( + "creative_mode_first_entry_reminder", + include_bytes!("../prompts/agents/creative_mode_first_entry_reminder.md"), + ), ( "deep_research_agent", include_bytes!("../prompts/agents/deep_research_agent.md"), diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 8c571d3dc6..e86b8dbe6f 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -163,6 +163,7 @@ product-full = [ "tools-computer-use", "tools-image-analysis", "tools-miniapp", + "tools-creation", "tools-canvas", "tools-agent-control", "function-agents", @@ -358,6 +359,9 @@ tools-miniapp = [ "dep:reqwest", "dep:semver", ] +tools-creation = [ + "bitfun-tool-packs/creation", +] tools-canvas = [ "bitfun-tool-packs/canvas", "canvas-runtime", diff --git a/src/crates/assembly/core/builtin_skills/bitfun-frontend-dev/SKILL.md b/src/crates/assembly/core/builtin_skills/bitfun-frontend-dev/SKILL.md new file mode 100644 index 0000000000..02ea150a21 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/bitfun-frontend-dev/SKILL.md @@ -0,0 +1,28 @@ +--- +name: bitfun-frontend-dev +description: Safely customize the running packaged BitFun desktop frontend through a draft, a provisional hot apply, and a 15-second user-confirmed rollback window. Use only in BitFun Creative mode when the user asks to change BitFun's own client UI. +--- + +# BitFun frontend customization + +Use this workflow only for the running BitFun desktop client's own frontend. It is not for a website in the user's workspace, a MiniApp, or a remote BitFun host. + +## Required workflow + +1. Call `FrontendWorkbench` with `action: "prepare"`. +2. Edit only the returned draft directory. Never edit the packaged resource directory, active revision, state file, or another draft. +3. Preserve a valid `index.html`. Prefer small changes to the stable `bitfun-creation.css` and `bitfun-creation.js` override files when those are sufficient. +4. Call `FrontendWorkbench` with `action: "apply"` and the exact returned `draft_id`. +5. Tell the user that the change is provisional and must be confirmed in the native 15-second confirmation window. +6. Call `FrontendWorkbench` with `action: "status"` before saying the revision was kept. A pending revision means it is not confirmed. + +`apply` replaces the running frontend immediately, but the desktop host owns the safety timer. If the user does not confirm within 15 seconds, if BitFun exits, or if the candidate cannot load, the host restores the prior revision. Never bypass or emulate the confirmation timer in page JavaScript. + +Use `action: "rollback"` when the user explicitly asks to undo the currently active customization. Do not delete revision history manually. + +## Boundaries + +- This capability is local-desktop-only. If the tool reports a remote or unsupported surface, explain that state; never fall back to a controller-local path. +- Do not call `FrontendWorkbench` outside Creative mode. +- Treat third-party code in a draft as untrusted. Do not add remote scripts, hidden telemetry, credential capture, or code that disables recovery controls. +- Keep Tauri invocation access intact. The confirmation window is immutable host UI and must remain independent of the editable frontend. diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs index d0f8814ffe..d65cf8a18d 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs @@ -49,9 +49,6 @@ impl ClawMode { // Local desktop/system control is delegated to the ComputerUse // agent/tool instead of being surfaced as a ControlHub domain. "ControlHub".to_string(), - "InitMiniApp".to_string(), - "FinalizeMiniApp".to_string(), - "PublishMiniApp".to_string(), "PublishAppearance".to_string(), "PageDeploy".to_string(), "PagePublish".to_string(), @@ -105,10 +102,12 @@ mod tests { use bitfun_agent_runtime::prompt::UserContextSection; #[test] - fn claw_mode_includes_miniapp_lifecycle_tools_in_defaults() { + fn claw_mode_excludes_creation_only_tools_from_defaults() { let tools = ClawMode::new().default_tools(); - assert!(tools.contains(&"InitMiniApp".to_string())); - assert!(tools.contains(&"FinalizeMiniApp".to_string())); + assert!(!tools.contains(&"InitMiniApp".to_string())); + assert!(!tools.contains(&"FinalizeMiniApp".to_string())); + assert!(!tools.contains(&"PublishMiniApp".to_string())); + assert!(!tools.contains(&"FrontendWorkbench".to_string())); assert!(tools.contains(&"ListModels".to_string())); } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs index 1fea14be23..9bd635d115 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs @@ -65,9 +65,6 @@ impl CoworkMode { // `wait` sends schedules here rather than pinning a turn open // for the interval. "Cron".to_string(), - "InitMiniApp".to_string(), - "FinalizeMiniApp".to_string(), - "PublishMiniApp".to_string(), "PublishAppearance".to_string(), ], } @@ -139,10 +136,12 @@ mod tests { } #[test] - fn cowork_mode_includes_miniapp_lifecycle_tools_in_defaults() { + fn cowork_mode_excludes_creation_only_tools_from_defaults() { let tools = CoworkMode::new().default_tools(); - assert!(tools.contains(&"InitMiniApp".to_string())); - assert!(tools.contains(&"FinalizeMiniApp".to_string())); + assert!(!tools.contains(&"InitMiniApp".to_string())); + assert!(!tools.contains(&"FinalizeMiniApp".to_string())); + assert!(!tools.contains(&"PublishMiniApp".to_string())); + assert!(!tools.contains(&"FrontendWorkbench".to_string())); assert!(tools.contains(&"ListModels".to_string())); } } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/creative.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/creative.rs new file mode 100644 index 0000000000..c6c7d3c98e --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/creative.rs @@ -0,0 +1,135 @@ +//! Creative Mode +//! +//! Owns product-creation capabilities that intentionally do not appear in the +//! default tool manifests of general coding, office, or assistant modes. + +use crate::agentic::agents::{ + get_embedded_prompt, shared_coding_mode_tool_exposure_overrides, shared_coding_mode_tools, + shared_coding_mode_user_context_policy, Agent, AgentToolPolicyOverrides, UserContextPolicy, + SHARED_CODING_MODE_PROMPT_TEMPLATE, +}; +use async_trait::async_trait; + +const CREATIVE_MODE_FIRST_ENTRY_REMINDER_TEMPLATE: &str = "creative_mode_first_entry_reminder"; + +pub struct CreativeMode { + default_tools: Vec, + tool_exposure_overrides: AgentToolPolicyOverrides, +} + +impl Default for CreativeMode { + fn default() -> Self { + Self::new() + } +} + +impl CreativeMode { + pub fn new() -> Self { + let mut default_tools = shared_coding_mode_tools(); + default_tools.extend( + [ + "InitMiniApp", + "FinalizeMiniApp", + "PublishMiniApp", + "FrontendWorkbench", + ] + .into_iter() + .map(str::to_string), + ); + Self { + default_tools, + tool_exposure_overrides: shared_coding_mode_tool_exposure_overrides(), + } + } +} + +#[async_trait] +impl Agent for CreativeMode { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + "Creative" + } + + fn name(&self) -> &str { + "Creative" + } + + fn description(&self) -> &str { + "Creation mode for building MiniApps and safely customizing the running BitFun frontend" + } + + fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { + SHARED_CODING_MODE_PROMPT_TEMPLATE + } + + fn default_tools(&self) -> Vec { + self.default_tools.clone() + } + + fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { + &self.tool_exposure_overrides + } + + fn user_context_policy(&self) -> UserContextPolicy { + shared_coding_mode_user_context_policy() + } + + async fn get_system_reminder( + &self, + previous_agent_type: Option<&str>, + _workspace: Option<&crate::agentic::WorkspaceBinding>, + ) -> crate::util::errors::BitFunResult { + if previous_agent_type == Some(self.id()) { + return Ok(String::new()); + } + get_embedded_prompt(CREATIVE_MODE_FIRST_ENTRY_REMINDER_TEMPLATE) + .map(str::to_string) + .ok_or_else(|| { + crate::util::errors::BitFunError::Agent(format!( + "{} not found in embedded files", + CREATIVE_MODE_FIRST_ENTRY_REMINDER_TEMPLATE + )) + }) + } + + fn is_readonly(&self) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::CreativeMode; + use crate::agentic::agents::Agent; + + #[test] + fn creative_mode_owns_product_creation_tools() { + let tools = CreativeMode::new().default_tools(); + for tool in [ + "InitMiniApp", + "FinalizeMiniApp", + "PublishMiniApp", + "FrontendWorkbench", + ] { + assert!(tools.contains(&tool.to_string()), "missing {tool}"); + } + } + + #[tokio::test] + async fn creative_reminder_is_only_injected_on_entry() { + let mode = CreativeMode::new(); + assert!(mode + .get_system_reminder(None, None) + .await + .expect("reminder") + .contains("FrontendWorkbench")); + assert!(mode + .get_system_reminder(Some("Creative"), None) + .await + .expect("ongoing reminder") + .is_empty()); + } +} diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/mod.rs index 692620ec6f..0b753ce031 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/mod.rs @@ -1,6 +1,7 @@ mod agentic; mod claw; mod cowork; +mod creative; mod deep_research; mod minimal; mod multitask; @@ -10,6 +11,7 @@ mod ultra; pub use agentic::AgenticMode; pub use claw::ClawMode; pub use cowork::CoworkMode; +pub use creative::CreativeMode; pub use deep_research::DeepResearchMode; pub use minimal::MinimalMode; pub use multitask::MultitaskMode; diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index d1829000f6..cd1c65294f 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -28,8 +28,8 @@ pub use definitions::custom::{CustomMode, CustomSubagent, CustomSubagentKind}; pub(crate) use definitions::external::ExternalProvidedAgent; pub use definitions::hidden::{CodeReviewAgent, DeepReviewAgent, GenerateDocAgent}; pub use definitions::modes::{ - AgenticMode, ClawMode, CoworkMode, DeepResearchMode, MinimalMode, MultitaskMode, PlanMode, - UltraMode, + AgenticMode, ClawMode, CoworkMode, CreativeMode, DeepResearchMode, MinimalMode, MultitaskMode, + PlanMode, UltraMode, }; pub use definitions::review::{ReviewFixerAgent, ReviewJudgeAgent, ReviewWorkerAgent}; pub use definitions::shared::ReadonlySubagent; @@ -157,9 +157,6 @@ pub fn shared_coding_mode_tools() -> Vec { // further out than an hour, to Cron rather than holding the turn open // for the interval. "Cron".to_string(), - "InitMiniApp".to_string(), - "FinalizeMiniApp".to_string(), - "PublishMiniApp".to_string(), "PublishAppearance".to_string(), "PageDeploy".to_string(), "PagePublish".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs b/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs index d93f0bc3cf..dca3385bb7 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs @@ -1,10 +1,11 @@ use super::types::AgentCategory; use super::visibility::SubagentVisibilityPolicy; use crate::agentic::agents::{ - Agent, AgenticMode, ClawMode, CodeReviewAgent, ComputerUseMode, CoworkMode, DeepResearchMode, - DeepReviewAgent, ExploreAgent, GeneralPurposeAgent, GenerateDocAgent, MinimalMode, - MultitaskMode, PlanMode, ResearchSpecialistAgent, ReviewFixerAgent, ReviewJudgeAgent, - ReviewWorkerAgent, SwarmPlannerAgent, SwarmReviewerAgent, SwarmWorkerAgent, UltraMode, + Agent, AgenticMode, ClawMode, CodeReviewAgent, ComputerUseMode, CoworkMode, CreativeMode, + DeepResearchMode, DeepReviewAgent, ExploreAgent, GeneralPurposeAgent, GenerateDocAgent, + MinimalMode, MultitaskMode, PlanMode, ResearchSpecialistAgent, ReviewFixerAgent, + ReviewJudgeAgent, ReviewWorkerAgent, SwarmPlannerAgent, SwarmReviewerAgent, SwarmWorkerAgent, + UltraMode, }; use crate::agentic::memories::MemoryPhase2Agent; use bitfun_agent_runtime::agents as runtime_agents; @@ -47,6 +48,7 @@ fn builtin_agent_factory(id: &str) -> fn() -> Arc { "minimal" => || Arc::new(MinimalMode::new()), "agentic" => || Arc::new(AgenticMode::new()), "Cowork" => || Arc::new(CoworkMode::new()), + "Creative" => || Arc::new(CreativeMode::new()), "Multitask" => || Arc::new(MultitaskMode::new()), "Plan" => || Arc::new(PlanMode::new()), "Claw" => || Arc::new(ClawMode::new()), diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 84262171bb..d628aa1362 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -236,6 +236,7 @@ fn top_level_modes_default_to_auto() { "agentic", "Multitask", "Cowork", + "Creative", "Plan", "Claw", "DeepResearch", @@ -342,6 +343,32 @@ fn every_builtin_standard_mode_defaults_to_the_thread_goal_lifecycle() { } } +#[test] +fn creation_tools_default_only_to_creative_mode() { + const CREATION_TOOLS: &[&str] = &[ + "InitMiniApp", + "FinalizeMiniApp", + "PublishMiniApp", + "FrontendWorkbench", + ]; + + for spec in builtin_agent_specs() + .iter() + .filter(|spec| spec.category == AgentCategory::Mode) + { + let mode = (spec.factory)(); + let default_tools = mode.default_tools(); + for tool_name in CREATION_TOOLS { + assert_eq!( + default_tools.iter().any(|tool| tool == tool_name), + mode.id() == "Creative", + "creation tool {tool_name} has unexpected default exposure in {}", + mode.id() + ); + } + } +} + #[test] fn every_builtin_mode_with_control_hub_can_also_schedule_with_cron() { // ControlHub's `wait` documentation tells the agent to schedule anything diff --git a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md index 20b42fa220..879d339f8f 100644 --- a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md +++ b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md @@ -47,6 +47,7 @@ Notes: | `InitMiniApp` | Direct | None | - | | `FinalizeMiniApp` | Direct | None | - | | `PublishMiniApp` | Direct | None | - | +| `FrontendWorkbench` | Direct | None | - | | `PublishAppearance` | Direct | None | - | | `ControlHub` | Deferred | `ComputerUse` | Direct | | `ComputerUse` | Deferred | `ComputerUse` | Direct | diff --git a/src/crates/assembly/core/src/agentic/tools/frontend_workbench_host.rs b/src/crates/assembly/core/src/agentic/tools/frontend_workbench_host.rs new file mode 100644 index 0000000000..dc85edb2a2 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/frontend_workbench_host.rs @@ -0,0 +1,42 @@ +//! Host boundary for the packaged-frontend workbench. +//! +//! Core owns the tool contract; the desktop adapter owns revision storage, +//! webview navigation, confirmation UI, and rollback timing. + +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, OnceLock}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrontendWorkbenchHostRequest { + pub action: String, + pub draft_id: Option, +} + +pub type FrontendWorkbenchFuture = + Pin> + Send + 'static>>; +pub type FrontendWorkbenchHandler = + Arc FrontendWorkbenchFuture + Send + Sync>; + +static FRONTEND_WORKBENCH_HANDLER: OnceLock = OnceLock::new(); + +pub fn set_frontend_workbench_handler(handler: FrontendWorkbenchHandler) { + let _ = FRONTEND_WORKBENCH_HANDLER.set(handler); +} + +pub fn frontend_workbench_host_available() -> bool { + FRONTEND_WORKBENCH_HANDLER.get().is_some() +} + +pub async fn invoke_frontend_workbench( + request: FrontendWorkbenchHostRequest, +) -> Result { + let Some(handler) = FRONTEND_WORKBENCH_HANDLER.get() else { + return Err("FrontendWorkbench is available only in the BitFun desktop app".to_string()); + }; + handler(request).await +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs new file mode 100644 index 0000000000..de656044d1 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs @@ -0,0 +1,229 @@ +//! Creative-mode tool for safely editing the running packaged frontend. + +use crate::agentic::tools::framework::{PermissionIntent, Tool, ToolResult, ToolUseContext}; +use crate::agentic::tools::frontend_workbench_host::{ + frontend_workbench_host_available, invoke_frontend_workbench, FrontendWorkbenchHostRequest, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +pub struct FrontendWorkbenchTool; + +impl FrontendWorkbenchTool { + pub fn new() -> Self { + Self + } +} + +impl Default for FrontendWorkbenchTool { + fn default() -> Self { + Self::new() + } +} + +fn creative_local_context(context: Option<&ToolUseContext>) -> bool { + context.is_some_and(|context| { + context.agent_type.as_deref() == Some("Creative") && !context.is_remote() + }) +} + +#[async_trait] +impl Tool for FrontendWorkbenchTool { + fn name(&self) -> &str { + "FrontendWorkbench" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Safely customize the frontend of the running packaged BitFun desktop client. Creative mode only. + +Workflow: call prepare, edit only the returned draft_path using file tools, then call apply with draft_id. Apply hot-loads the candidate and starts an authoritative 15-second confirmation window in immutable native-host UI. If the user does not confirm, BitFun automatically restores the previous revision. Call status before claiming that a revision was kept. rollback explicitly restores the previous confirmed revision. + +Actions: +- prepare: create an editable draft from the current active frontend. +- status: inspect active/pending revision state. +- apply: validate and provisionally activate a prepared draft; requires fresh permission. +- rollback: restore the previous confirmed revision; requires fresh permission. + +This tool is unavailable for remote workspaces and non-desktop surfaces."#.to_string()) + } + + fn short_description(&self) -> String { + "Draft and hot-apply the packaged BitFun frontend with 15-second rollback protection." + .to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["action"], + "properties": { + "action": { + "type": "string", + "enum": ["prepare", "status", "apply", "rollback"] + }, + "draft_id": { + "type": "string", + "description": "Exact draft id returned by prepare. Required for apply." + } + } + }) + } + + fn is_readonly(&self) -> bool { + false + } + + async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { + frontend_workbench_host_available() && creative_local_context(context) + } + + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let action = input.get("action").and_then(Value::as_str).unwrap_or(""); + if !matches!(action, "apply" | "rollback") { + return Ok(Vec::new()); + } + + let resource = if action == "apply" { + input + .get("draft_id") + .and_then(Value::as_str) + .map(|id| format!("bitfun-frontend:draft:{id}")) + .unwrap_or_else(|| "bitfun-frontend:draft:".to_string()) + } else { + "bitfun-frontend:previous-confirmed-revision".to_string() + }; + let mut intent = PermissionIntent::new("frontend_workbench", vec![resource]); + intent.save_resources.clear(); + intent + .display_metadata + .insert("requiresFreshApproval".to_string(), Value::Bool(true)); + Ok(vec![intent]) + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + if context.agent_type.as_deref() != Some("Creative") { + return Err(BitFunError::tool( + "FrontendWorkbench is restricted to Creative mode".to_string(), + )); + } + if context.is_remote() { + return Err(BitFunError::tool( + "FrontendWorkbench cannot modify a remote workspace or remote BitFun host" + .to_string(), + )); + } + + let action = input + .get("action") + .and_then(Value::as_str) + .ok_or_else(|| BitFunError::validation("Missing required field: action"))?; + let draft_id = input + .get("draft_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if action == "apply" && draft_id.is_none() { + return Err(BitFunError::validation( + "draft_id is required when action is apply", + )); + } + + let result = invoke_frontend_workbench(FrontendWorkbenchHostRequest { + action: action.to_string(), + draft_id, + }) + .await + .map_err(BitFunError::tool)?; + let assistant = match action { + "prepare" => "Frontend draft prepared. Edit only draft_path, then apply the exact draft_id.", + "apply" => "Frontend candidate is live provisionally. The user must confirm it within 15 seconds or BitFun will roll it back automatically.", + "rollback" => "Frontend rollback requested.", + _ => "Frontend workbench status returned.", + }; + + Ok(vec![ToolResult::Result { + data: result, + result_for_assistant: Some(assistant.to_string()), + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn context(agent_type: &str) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: Some(agent_type.to_string()), + session_id: None, + dialog_turn_id: None, + workspace: None, + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: Default::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: Default::default(), + } + } + + #[tokio::test] + async fn execution_rejects_non_creative_agents_even_if_called_directly() { + let error = FrontendWorkbenchTool::new() + .call_impl(&json!({"action": "status"}), &context("agentic")) + .await + .expect_err("non-Creative call must fail"); + assert!(error.to_string().contains("Creative mode")); + } + + #[tokio::test] + async fn execution_rejects_remote_workspaces_even_in_creative_mode() { + let mut remote = context("Creative"); + remote.workspace = Some(crate::agentic::WorkspaceBinding::new_remote( + None, + std::path::PathBuf::from("/srv/project"), + "connection-1".to_string(), + "Remote".to_string(), + crate::service::remote_ssh::workspace_state::WorkspaceSessionIdentity { + hostname: "remote.example".to_string(), + logical_workspace_path: "/srv/project".to_string(), + remote_connection_id: Some("connection-1".to_string()), + }, + )); + + let error = FrontendWorkbenchTool::new() + .call_impl(&json!({"action": "status"}), &remote) + .await + .expect_err("remote Creative calls must fail"); + assert!(error.to_string().contains("remote workspace")); + } + + #[test] + fn apply_always_requires_fresh_approval() { + let intents = FrontendWorkbenchTool::new() + .permission_intents( + &json!({"action": "apply", "draft_id": "draft-1"}), + &context("Creative"), + ) + .expect("permission intent"); + assert!(intents[0].save_resources.is_empty()); + assert_eq!( + intents[0].display_metadata.get("requiresFreshApproval"), + Some(&Value::Bool(true)) + ); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index c137a00831..bf8e3208b0 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -31,6 +31,8 @@ pub mod exec_command; pub mod file_edit_tool; pub mod file_read_tool; pub mod file_write_tool; +#[cfg(feature = "tools-creation")] +pub mod frontend_workbench_tool; #[cfg(feature = "tools-miniapp")] pub mod generative_ui_tool; #[cfg(feature = "tools-git")] @@ -103,6 +105,8 @@ pub use exec_command::{ExecCommandTool, ExecControlTool, WriteStdinTool}; pub use file_edit_tool::FileEditTool; pub use file_read_tool::FileReadTool; pub use file_write_tool::FileWriteTool; +#[cfg(feature = "tools-creation")] +pub use frontend_workbench_tool::FrontendWorkbenchTool; #[cfg(feature = "tools-miniapp")] pub use generative_ui_tool::GenerativeUITool; #[cfg(feature = "tools-git")] diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs index fab15fbc46..9a09286464 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs @@ -22,6 +22,10 @@ mod tests { Some("debugging") ); assert_eq!(builtin_skill_group_key("miniapp-dev"), Some("miniapp")); + assert_eq!( + builtin_skill_group_key("bitfun-frontend-dev"), + Some("creation") + ); assert_eq!(builtin_skill_group_key("writing-skills"), Some("meta")); assert_eq!( builtin_skill_group_key("agent-browser"), diff --git a/src/crates/assembly/core/src/agentic/tools/mod.rs b/src/crates/assembly/core/src/agentic/tools/mod.rs index ab56b0e9b1..a7b4275315 100644 --- a/src/crates/assembly/core/src/agentic/tools/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/mod.rs @@ -12,6 +12,8 @@ pub(crate) mod file_permissions; pub mod file_read_state_runtime; pub mod file_tool_guidance; pub mod framework; +#[cfg(feature = "tools-creation")] +pub mod frontend_workbench_host; pub mod image_context; pub mod implementations; pub mod manifest_resolver; diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index bbd589c5b5..084430ad02 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -73,6 +73,7 @@ const PRODUCT_TOOL_REGISTRATION_ORDER: &[&str] = &[ "InitMiniApp", "FinalizeMiniApp", "PublishMiniApp", + "FrontendWorkbench", "PublishAppearance", "PageDeploy", "PagePublish", @@ -183,6 +184,8 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "FinalizeMiniApp" => Some(Arc::new(FinalizeMiniAppTool::new())), #[cfg(feature = "tools-miniapp")] "PublishMiniApp" => Some(Arc::new(PublishMiniAppTool::new())), + #[cfg(feature = "tools-creation")] + "FrontendWorkbench" => Some(Arc::new(FrontendWorkbenchTool::new())), #[cfg(feature = "tools-miniapp")] "PublishAppearance" => Some(Arc::new(PublishAppearanceTool::new())), #[cfg(feature = "tools-miniapp")] diff --git a/src/crates/assembly/product-capabilities/src/lib.rs b/src/crates/assembly/product-capabilities/src/lib.rs index 5c451482ce..df963609c0 100644 --- a/src/crates/assembly/product-capabilities/src/lib.rs +++ b/src/crates/assembly/product-capabilities/src/lib.rs @@ -22,6 +22,7 @@ pub enum ProductCapabilityId { DeepReview, DeepResearch, MiniApp, + Creation, Canvas, VoiceInput, } @@ -33,6 +34,7 @@ impl ProductCapabilityId { Self::DeepReview => "deep-review", Self::DeepResearch => "deep-research", Self::MiniApp => "miniapp", + Self::Creation => "creation", Self::Canvas => "canvas", Self::VoiceInput => "voice-input", } @@ -55,6 +57,7 @@ pub enum ProductFeatureGroup { ComputerUse, ImageAnalysis, MiniApp, + Creation, Canvas, AgentControl, } @@ -69,6 +72,7 @@ impl ProductFeatureGroup { Self::ComputerUse => "computer-use", Self::ImageAnalysis => "image-analysis", Self::MiniApp => "miniapp", + Self::Creation => "creation", Self::Canvas => "canvas", Self::AgentControl => "agent-control", } @@ -91,6 +95,7 @@ impl From for ProductFeatureGroup { ToolPackFeatureGroup::ComputerUse => Self::ComputerUse, ToolPackFeatureGroup::ImageAnalysis => Self::ImageAnalysis, ToolPackFeatureGroup::MiniApp => Self::MiniApp, + ToolPackFeatureGroup::Creation => Self::Creation, ToolPackFeatureGroup::Canvas => Self::Canvas, ToolPackFeatureGroup::AgentControl => Self::AgentControl, } @@ -107,6 +112,7 @@ impl From for ToolPackFeatureGroup { ProductFeatureGroup::ComputerUse => Self::ComputerUse, ProductFeatureGroup::ImageAnalysis => Self::ImageAnalysis, ProductFeatureGroup::MiniApp => Self::MiniApp, + ProductFeatureGroup::Creation => Self::Creation, ProductFeatureGroup::Canvas => Self::Canvas, ProductFeatureGroup::AgentControl => Self::AgentControl, } @@ -1005,6 +1011,7 @@ const CODE_AGENT_TOOL_GROUPS: &[&str] = &[ const DEEP_REVIEW_TOOL_GROUPS: &[&str] = &["core.review"]; const DEEP_RESEARCH_TOOL_GROUPS: &[&str] = &["core.web", "core.mcp"]; const MINIAPP_TOOL_GROUPS: &[&str] = &["core.miniapp"]; +const CREATION_TOOL_GROUPS: &[&str] = &["core.creation", "core.miniapp"]; const CANVAS_TOOL_GROUPS: &[&str] = &["core.canvas"]; const CODE_AGENT_IDS: &[&str] = &[ @@ -1032,6 +1039,7 @@ const DEEP_REVIEW_AGENT_IDS: &[&str] = &[ "DeepReview", ]; const DEEP_RESEARCH_AGENT_IDS: &[&str] = &["DeepResearch", "ResearchSpecialist"]; +const CREATION_AGENT_IDS: &[&str] = &["Creative"]; const NO_PRODUCT_AGENTS: &[&str] = &[]; const CODE_AGENT_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack::new( @@ -1058,6 +1066,12 @@ const MINIAPP_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack::ne MINIAPP_TOOL_GROUPS, NO_PRODUCT_AGENTS, ); +const CREATION_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack::new( + ProductCapabilityId::Creation, + MINIAPP_SERVICES, + CREATION_TOOL_GROUPS, + CREATION_AGENT_IDS, +); const CANVAS_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack::new( ProductCapabilityId::Canvas, CANVAS_SERVICES, @@ -1072,6 +1086,7 @@ const DEFAULT_PRODUCT_CAPABILITY_PACKS: &[ProductCapabilityPack] = &[ DEEP_REVIEW_CAPABILITY_PACK, DEEP_RESEARCH_CAPABILITY_PACK, MINIAPP_CAPABILITY_PACK, + CREATION_CAPABILITY_PACK, CANVAS_CAPABILITY_PACK, VOICE_INPUT_CAPABILITY_PACK, ]; diff --git a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs index 5d141beace..874ab0462e 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs @@ -142,6 +142,7 @@ fn default_capability_registry_preserves_product_tool_provider_order() { "core.computer-use", "core.review", "core.miniapp", + "core.creation", "core.canvas", ] ); @@ -163,6 +164,7 @@ fn capability_packs_describe_service_and_tool_requirements() { "deep-review", "deep-research", "miniapp", + "creation", "canvas", "voice-input" ] @@ -181,6 +183,7 @@ fn product_assembly_plan_keeps_full_capabilities_only_for_core_compatibility_pro "deep-review", "deep-research", "miniapp", + "creation", "canvas", ]; let full_tool_groups = vec![ @@ -193,6 +196,7 @@ fn product_assembly_plan_keeps_full_capabilities_only_for_core_compatibility_pro "core.computer-use", "core.review", "core.miniapp", + "core.creation", "core.canvas", ]; @@ -457,6 +461,7 @@ fn product_assembly_plan_exposes_build_feature_groups_explicitly() { ProductFeatureGroup::Mcp, ProductFeatureGroup::ComputerUse, ProductFeatureGroup::MiniApp, + ProductFeatureGroup::Creation, ProductFeatureGroup::Canvas, ] ); @@ -471,6 +476,7 @@ fn product_assembly_plan_exposes_build_feature_groups_explicitly() { "mcp", "computer-use", "miniapp", + "creation", "canvas", ] ); @@ -768,6 +774,7 @@ fn default_capability_assembly_keeps_service_and_tool_facts_together() { "deep-review", "deep-research", "miniapp", + "creation", "canvas", "voice-input" ] @@ -805,6 +812,7 @@ fn default_capability_assembly_keeps_service_and_tool_facts_together() { "core.computer-use", "core.review", "core.miniapp", + "core.creation", "core.canvas", ] ); diff --git a/src/crates/execution/agent-runtime/src/agents.rs b/src/crates/execution/agent-runtime/src/agents.rs index aa4bbe38c4..7cc5be00b6 100644 --- a/src/crates/execution/agent-runtime/src/agents.rs +++ b/src/crates/execution/agent-runtime/src/agents.rs @@ -50,6 +50,7 @@ pub fn mode_presentation_rank(mode_id: &str) -> u8 { "Multitask" => 3, "DeepResearch" => 4, "Ultra" => 5, + "Creative" => 6, _ => 99, } } @@ -84,6 +85,12 @@ pub fn builtin_agent_definition_specs() -> Vec { builtin_agent_spec("minimal", Mode, "auto", SubagentVisibilityPolicy::default()), builtin_agent_spec("agentic", Mode, "auto", SubagentVisibilityPolicy::default()), builtin_agent_spec("Cowork", Mode, "auto", SubagentVisibilityPolicy::default()), + builtin_agent_spec( + "Creative", + Mode, + "auto", + SubagentVisibilityPolicy::default(), + ), builtin_agent_spec( "Multitask", Mode, @@ -188,8 +195,8 @@ pub fn builtin_agent_definition_specs() -> Vec { pub fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static str { match agent_type { - "minimal" | "agentic" | "Cowork" | "ComputerUse" | "Plan" | "Claw" | "DeepResearch" - | "Multitask" | "Ultra" => "auto", + "minimal" | "agentic" | "Cowork" | "Creative" | "ComputerUse" | "Plan" | "Claw" + | "DeepResearch" | "Multitask" | "Ultra" => "auto", "Explore" | "CodeReview" | "GeneralPurpose" | "MemoryPhase2" | "SwarmPlanner" | "SwarmWorker" => "primary", "GenerateDoc" diff --git a/src/crates/execution/agent-runtime/src/skills/catalog.rs b/src/crates/execution/agent-runtime/src/skills/catalog.rs index 5a946021dd..9d84df4ece 100644 --- a/src/crates/execution/agent-runtime/src/skills/catalog.rs +++ b/src/crates/execution/agent-runtime/src/skills/catalog.rs @@ -3,6 +3,7 @@ pub(super) enum BuiltinSkillGroup { Office, Meta, MiniApp, + Creation, ComputerUse, Canvas, Debugging, @@ -15,6 +16,7 @@ impl BuiltinSkillGroup { Self::Office => "office", Self::Meta => "meta", Self::MiniApp => "miniapp", + Self::Creation => "creation", Self::ComputerUse => "computer-use", Self::Canvas => "canvas", Self::Debugging => "debugging", @@ -62,6 +64,10 @@ pub(super) const BUILTIN_SKILL_SPECS: &[BuiltinSkillSpec] = &[ dir_name: "miniapp-dev", group: BuiltinSkillGroup::MiniApp, }, + BuiltinSkillSpec { + dir_name: "bitfun-frontend-dev", + group: BuiltinSkillGroup::Creation, + }, BuiltinSkillSpec { dir_name: "gstack-autoplan", group: BuiltinSkillGroup::Gstack, diff --git a/src/crates/execution/agent-runtime/src/skills/policy.rs b/src/crates/execution/agent-runtime/src/skills/policy.rs index b99a8cf592..939351d1c0 100644 --- a/src/crates/execution/agent-runtime/src/skills/policy.rs +++ b/src/crates/execution/agent-runtime/src/skills/policy.rs @@ -6,6 +6,7 @@ enum SkillModeId { CodingShared, Cowork, Claw, + Creative, ComputerUse, DeepResearch, Other, @@ -17,6 +18,7 @@ impl SkillModeId { SHARED_CODING_MODE_CONFIG_PROFILE_ID => Self::CodingShared, "Cowork" => Self::Cowork, "Claw" => Self::Claw, + "Creative" => Self::Creative, "ComputerUse" => Self::ComputerUse, "DeepResearch" => Self::DeepResearch, _ => Self::Other, @@ -63,6 +65,16 @@ const DISABLE_GSTACK: SkillPolicyRule = SkillPolicyRule { effect: PolicyEffect::Disable, }; +const DISABLE_MINIAPP: SkillPolicyRule = SkillPolicyRule { + selector: SkillSelector::Group(BuiltinSkillGroup::MiniApp), + effect: PolicyEffect::Disable, +}; + +const DISABLE_CREATION: SkillPolicyRule = SkillPolicyRule { + selector: SkillSelector::Group(BuiltinSkillGroup::Creation), + effect: PolicyEffect::Disable, +}; + // ControlHub's browser domain is the single default browser-automation path. // The computer-use skill group (agent-browser) stays opt-in in every mode so the // model never sees two parallel browser stacks; users can still enable it via @@ -88,6 +100,17 @@ const OPEN_META_ONLY_POLICY: ModeSkillPolicy = ModeSkillPolicy { }; const AGENTIC_POLICY: ModeSkillPolicy = ModeSkillPolicy { + builtin_default: PolicyEffect::Enable, + rules: &[ + DISABLE_OFFICE, + DISABLE_GSTACK, + DISABLE_COMPUTER_USE, + DISABLE_MINIAPP, + DISABLE_CREATION, + ], +}; + +const CREATIVE_POLICY: ModeSkillPolicy = ModeSkillPolicy { builtin_default: PolicyEffect::Enable, rules: &[DISABLE_OFFICE, DISABLE_GSTACK, DISABLE_COMPUTER_USE], }; @@ -101,6 +124,7 @@ fn policy_for_mode(mode_id: &str) -> ModeSkillPolicy { let policy_scope = resolve_mode_config_profile_id(mode_id); match SkillModeId::parse(policy_scope.as_ref()) { SkillModeId::CodingShared | SkillModeId::Claw => AGENTIC_POLICY, + SkillModeId::Creative => CREATIVE_POLICY, SkillModeId::Cowork => COWORK_POLICY, SkillModeId::ComputerUse | SkillModeId::DeepResearch | SkillModeId::Other => { OPEN_META_ONLY_POLICY @@ -146,6 +170,7 @@ mod tests { "Multitask", "coding_shared", "Claw", + "Creative", "Cowork", "ComputerUse", "DeepResearch", @@ -193,4 +218,28 @@ mod tests { ); } } + + #[test] + fn product_creation_skills_default_only_in_creative_mode() { + for skill in ["miniapp-dev", "bitfun-frontend-dev"] { + for mode_id in [ + "agentic", + "Plan", + "Multitask", + "coding_shared", + "Claw", + "Creative", + "Cowork", + "ComputerUse", + "DeepResearch", + "SomeUnknownMode", + ] { + assert_eq!( + resolve_builtin_default_enabled(skill, mode_id), + Some(mode_id == "Creative"), + "creation skill {skill} has unexpected default exposure in {mode_id}" + ); + } + } + } } diff --git a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/agent_registry_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/agent_registry_contracts.rs index 65c9de9e29..4aa80dce0d 100644 --- a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/agent_registry_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/agent_registry_contracts.rs @@ -182,6 +182,7 @@ fn mode_presentation_and_shared_context_policy_match_existing_mode_contract() { assert_eq!(mode_presentation_rank("agentic"), 0); assert_eq!(mode_presentation_rank("Cowork"), 1); assert_eq!(mode_presentation_rank("Ultra"), 5); + assert_eq!(mode_presentation_rank("Creative"), 6); assert_eq!(mode_presentation_rank("unknown"), 99); assert_eq!( @@ -200,6 +201,7 @@ fn builtin_agent_definition_catalog_preserves_order_categories_models_and_visibi "minimal", "agentic", "Cowork", + "Creative", "Multitask", "Plan", "Claw", diff --git a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs index 05f1a8e66d..960bfbfd9f 100644 --- a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs @@ -287,6 +287,10 @@ fn builtin_skill_catalog_and_mode_policy_are_runtime_owned() { assert_eq!(builtin_skill_group_key("create-bitfun-skin"), Some("meta")); assert_eq!(builtin_skill_group_key("find-skills"), Some("meta")); assert_eq!(builtin_skill_group_key("miniapp-dev"), Some("miniapp")); + assert_eq!( + builtin_skill_group_key("bitfun-frontend-dev"), + Some("creation") + ); assert_eq!( builtin_skill_group_key("agent-browser"), Some("computer-use") @@ -316,7 +320,7 @@ fn builtin_skill_catalog_and_mode_policy_are_runtime_owned() { ); assert_eq!( resolve_builtin_default_enabled("miniapp-dev", "agentic"), - Some(true) + Some(false) ); assert_eq!( resolve_builtin_default_enabled("miniapp-dev", "Cowork"), @@ -326,6 +330,18 @@ fn builtin_skill_catalog_and_mode_policy_are_runtime_owned() { resolve_builtin_default_enabled("miniapp-dev", "DeepResearch"), Some(false) ); + assert_eq!( + resolve_builtin_default_enabled("miniapp-dev", "Creative"), + Some(true) + ); + assert_eq!( + resolve_builtin_default_enabled("bitfun-frontend-dev", "Creative"), + Some(true) + ); + assert_eq!( + resolve_builtin_default_enabled("bitfun-frontend-dev", "agentic"), + Some(false) + ); assert_eq!( resolve_builtin_default_enabled("agent-browser", "coding_shared"), Some(false) diff --git a/src/crates/execution/tool-provider-groups/Cargo.toml b/src/crates/execution/tool-provider-groups/Cargo.toml index 7d4d81ac48..894bbf499a 100644 --- a/src/crates/execution/tool-provider-groups/Cargo.toml +++ b/src/crates/execution/tool-provider-groups/Cargo.toml @@ -18,9 +18,10 @@ browser-web = [] computer-use = [] image-analysis = [] miniapp = [] +creation = [] canvas = [] agent-control = [] -product-full = ["basic", "git", "mcp", "browser-web", "computer-use", "image-analysis", "miniapp", "canvas", "agent-control"] +product-full = ["basic", "git", "mcp", "browser-web", "computer-use", "image-analysis", "miniapp", "creation", "canvas", "agent-control"] [lints] workspace = true diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 66715505fc..11f217d552 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -16,6 +16,7 @@ pub enum ToolPackFeatureGroup { ComputerUse, ImageAnalysis, MiniApp, + Creation, Canvas, AgentControl, } @@ -30,6 +31,7 @@ impl ToolPackFeatureGroup { Self::ComputerUse => "computer-use", Self::ImageAnalysis => "image-analysis", Self::MiniApp => "miniapp", + Self::Creation => "creation", Self::Canvas => "canvas", Self::AgentControl => "agent-control", } @@ -44,6 +46,7 @@ pub const ALL_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ ToolPackFeatureGroup::ComputerUse, ToolPackFeatureGroup::ImageAnalysis, ToolPackFeatureGroup::MiniApp, + ToolPackFeatureGroup::Creation, ToolPackFeatureGroup::Canvas, ToolPackFeatureGroup::AgentControl, ]; @@ -70,6 +73,7 @@ pub fn enabled_feature_groups() -> Vec { ToolPackFeatureGroup::ImageAnalysis, ), (cfg!(feature = "miniapp"), ToolPackFeatureGroup::MiniApp), + (cfg!(feature = "creation"), ToolPackFeatureGroup::Creation), (cfg!(feature = "canvas"), ToolPackFeatureGroup::Canvas), ( cfg!(feature = "agent-control"), @@ -98,6 +102,7 @@ pub fn tool_feature_group(tool_name: &str) -> Option { | "PublishAppearance" | "PageDeploy" | "PagePublish" | "Playbook" => { Some(ToolPackFeatureGroup::MiniApp) } + "FrontendWorkbench" => Some(ToolPackFeatureGroup::Creation), "CreateCanvas" | "ReadCanvas" | "UpdateCanvas" | "PatchCanvas" => { Some(ToolPackFeatureGroup::Canvas) } @@ -158,6 +163,7 @@ const CORE_MCP_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup: const CORE_COMPUTER_USE_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::ComputerUse]; const CORE_MINIAPP_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::MiniApp]; +const CORE_CREATION_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::Creation]; const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ ToolProviderGroupPlan { @@ -258,6 +264,11 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "Playbook", ], }, + ToolProviderGroupPlan { + provider_id: "core.creation", + feature_groups: CORE_CREATION_FEATURE_GROUPS, + tool_names: &["FrontendWorkbench"], + }, ToolProviderGroupPlan { provider_id: "core.canvas", feature_groups: CORE_CANVAS_FEATURE_GROUPS, @@ -338,6 +349,7 @@ mod tests { "computer-use", "image-analysis", "miniapp", + "creation", "canvas", "agent-control" ] @@ -376,6 +388,10 @@ mod tests { groups.contains(&ToolPackFeatureGroup::MiniApp), cfg!(feature = "miniapp") ); + assert_eq!( + groups.contains(&ToolPackFeatureGroup::Creation), + cfg!(feature = "creation") + ); assert_eq!( groups.contains(&ToolPackFeatureGroup::Canvas), cfg!(feature = "canvas") @@ -454,6 +470,7 @@ mod tests { assert_eq!(ToolPackFeatureGroup::ComputerUse.id(), "computer-use"); assert_eq!(ToolPackFeatureGroup::ImageAnalysis.id(), "image-analysis"); assert_eq!(ToolPackFeatureGroup::MiniApp.id(), "miniapp"); + assert_eq!(ToolPackFeatureGroup::Creation.id(), "creation"); assert_eq!(ToolPackFeatureGroup::Canvas.id(), "canvas"); assert_eq!(ToolPackFeatureGroup::AgentControl.id(), "agent-control"); } @@ -477,6 +494,7 @@ mod tests { "core.computer-use", "core.review", "core.miniapp", + "core.creation", "core.canvas", ] ); @@ -550,6 +568,7 @@ mod tests { "PageDeploy", "PagePublish", "Playbook", + "FrontendWorkbench", "CreateCanvas", "ReadCanvas", "UpdateCanvas", @@ -586,6 +605,7 @@ mod tests { ("core.computer-use", vec!["computer-use"]), ("core.review", vec!["agent-control"]), ("core.miniapp", vec!["miniapp"]), + ("core.creation", vec!["creation"]), ("core.canvas", vec!["canvas"]), ] ); diff --git a/src/web-ui/index.html b/src/web-ui/index.html index cfc92fa139..f2c5de3289 100644 --- a/src/web-ui/index.html +++ b/src/web-ui/index.html @@ -346,6 +346,9 @@ } } + + + diff --git a/src/web-ui/public/bitfun-creation.css b/src/web-ui/public/bitfun-creation.css new file mode 100644 index 0000000000..4342f9fb01 --- /dev/null +++ b/src/web-ui/public/bitfun-creation.css @@ -0,0 +1,5 @@ +/* + * Stable Creative-mode override surface for the packaged BitFun frontend. + * Keep empty by default. Creative agents should prefer changes here when CSS + * alone can implement the requested customization. + */ diff --git a/src/web-ui/public/bitfun-creation.js b/src/web-ui/public/bitfun-creation.js new file mode 100644 index 0000000000..edf776a7d4 --- /dev/null +++ b/src/web-ui/public/bitfun-creation.js @@ -0,0 +1,4 @@ +/* + * Stable Creative-mode override surface for the packaged BitFun frontend. + * Keep side effects reversible and preserve the Tauri bridge and recovery UI. + */ diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx index e391b97608..4202152bb2 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx @@ -9,6 +9,10 @@ import { } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { useSceneManager } from '@/app/hooks/useSceneManager'; +import { useApp } from '@/app/hooks/useApp'; +import { flowChatSessionConfigForCurrentWorkspace } from '@/app/utils/projectSessionWorkspace'; +import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; +import { isRemoteWorkspace } from '@/shared/types'; import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; import MiniAppCard from '../components/MiniAppCard'; import MiniAppDetailModal from '../components/MiniAppDetailModal'; @@ -56,8 +60,9 @@ const MiniAppGalleryView: React.FC = () => { const setMarketOrigins = useMiniAppStore((state) => state.setMarketOrigins); const setRunningWorkerIds = useMiniAppStore((state) => state.setRunningWorkerIds); const markWorkerStopped = useMiniAppStore((state) => state.markWorkerStopped); - const { workspacePath } = useCurrentWorkspace(); + const { workspace, workspacePath } = useCurrentWorkspace(); const notification = useNotification(); + const { switchLeftPanelTab } = useApp(); const { openScene, activateScene, closeScene, openTabs } = useSceneManager(); const { t, currentLanguage } = useI18n('scenes/miniapp'); const miniAppActivities = useMiniAppActivity(); @@ -71,7 +76,7 @@ const MiniAppGalleryView: React.FC = () => { inspection: MarketPackageInspection; } | null>(null); const [importMenuOpen, setImportMenuOpen] = useState(false); - const [creationModeNoticeOpen, setCreationModeNoticeOpen] = useState(false); + const [creatingWithCreative, setCreatingWithCreative] = useState(false); const importTriggerRef = useRef(null); const importMenuRef = useRef(null); @@ -322,6 +327,43 @@ const MiniAppGalleryView: React.FC = () => { } }; + const handleCreateWithCreative = useCallback(async () => { + if (creatingWithCreative) return; + if (!workspace) { + notification.error(t('creationMode.noWorkspace')); + return; + } + if (isRemoteWorkspace(workspace)) { + notification.error(t('creationMode.remoteUnsupported')); + return; + } + + setCreatingWithCreative(true); + closeImportMenu(); + openScene('session'); + switchLeftPanelTab('sessions'); + try { + await flowChatManager.createChatSession( + flowChatSessionConfigForCurrentWorkspace(workspace), + 'Creative', + ); + notification.success(t('creationMode.started')); + } catch (error) { + log.error('Failed to start Creative MiniApp session', error); + notification.error(t('creationMode.startFailed')); + } finally { + setCreatingWithCreative(false); + } + }, [ + closeImportMenu, + creatingWithCreative, + notification, + openScene, + switchLeftPanelTab, + t, + workspace, + ]); + const renderGrid = () => { if (loading && apps.length === 0) { return ( @@ -442,10 +484,8 @@ const MiniAppGalleryView: React.FC = () => { { - closeImportMenu(); - setCreationModeNoticeOpen(true); - }} + onClick={() => void handleCreateWithCreative()} + loading={creatingWithCreative} title={t('creationMode.action')} aria-label={t('creationMode.action')} data-testid="miniapp-create-action" @@ -530,17 +570,6 @@ const MiniAppGalleryView: React.FC = () => { onStop={handleStopRunning} /> - setCreationModeNoticeOpen(false)} - onConfirm={() => setCreationModeNoticeOpen(false)} - title={t('creationMode.unavailableTitle')} - message={t('creationMode.unavailableMessage')} - type="info" - showCancel={false} - confirmText={t('creationMode.acknowledge')} - /> - setPendingDeleteId(null)} diff --git a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.test.tsx b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.test.tsx index 78471809fb..5b90982e2f 100644 --- a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.test.tsx +++ b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.test.tsx @@ -160,7 +160,7 @@ describe('HarnessProfileSelector', () => { const creative = rows[3]; expect(creative?.querySelector('.bitfun-harness-selector__density-core')).toBeNull(); expect(creative?.querySelector('[data-bf-icon="harness-creative"]')).not.toBeNull(); - expect(creative?.dataset.bfState).toBe('coming-soon'); + expect(creative?.dataset.bfState).toBe('available'); const other = rows[4]; expect(other?.querySelector('.bitfun-harness-selector__density-core')).toBeNull(); expect(other?.querySelector('.lucide-bot')).not.toBeNull(); @@ -223,7 +223,7 @@ describe('HarnessProfileSelector', () => { ).toBe('Deep Research · Multitask'); }); - it('activates all implemented profiles while reporting Creative as unavailable', async () => { + it('activates every implemented profile including Creative', async () => { const onSelectProfile = vi.fn(); await act(async () => { root.render(); @@ -247,8 +247,9 @@ describe('HarnessProfileSelector', () => { document.querySelector('[data-testid="harness-profile-creative"]') ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - expect(notify.info).toHaveBeenCalledTimes(1); - expect(onSelectProfile).toHaveBeenCalledTimes(1); + expect(notify.info).not.toHaveBeenCalled(); + expect(onSelectProfile).toHaveBeenCalledTimes(2); + expect(onSelectProfile).toHaveBeenLastCalledWith('creative'); await act(async () => { container.querySelector('[data-testid="harness-profile-selector"]') @@ -258,7 +259,7 @@ describe('HarnessProfileSelector', () => { document.querySelector('[data-testid="harness-profile-minimal"]') ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - expect(onSelectProfile).toHaveBeenCalledTimes(2); + expect(onSelectProfile).toHaveBeenCalledTimes(3); expect(onSelectProfile).toHaveBeenLastCalledWith('minimal'); expect(onSelectProfile).toHaveBeenCalledWith('minimal'); expect(document.querySelector('.bitfun-harness-selector__menu')).toBeNull(); @@ -383,7 +384,7 @@ describe('HarnessProfileSelector', () => { }); it.each(['creative'] as const)( - 'does not present a persisted %s profile as active', + 'presents a persisted %s profile as active', async (profileId) => { await act(async () => { root.render(); @@ -396,8 +397,8 @@ describe('HarnessProfileSelector', () => { const profile = document.querySelector( `[data-testid="harness-profile-${profileId}"]`, ); - expect(profile?.dataset.bfState).toBe('coming-soon'); - expect(profile?.getAttribute('aria-checked')).toBe('false'); + expect(profile?.dataset.bfState).toBe('current'); + expect(profile?.getAttribute('aria-checked')).toBe('true'); }, ); diff --git a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx index a56336da69..1ce2904ed3 100644 --- a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx +++ b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx @@ -26,7 +26,7 @@ export type KnownHarnessProfileId = | 'ultimate' | 'creative' | 'other'; -export type SelectableHarnessProfileId = 'minimal' | 'balanced' | 'ultimate'; +export type SelectableHarnessProfileId = 'minimal' | 'balanced' | 'ultimate' | 'creative'; export interface HarnessAgentOption { id: string; @@ -83,11 +83,7 @@ function isDensityProfile(profile: KnownHarnessProfileId): profile is DensityHar function isSelectableProfile( profile: KnownHarnessProfileId, ): profile is SelectableHarnessProfileId { - return isDensityProfile(profile); -} - -function isProfileInDevelopment(profile: KnownHarnessProfileId): boolean { - return profile === 'creative'; + return isDensityProfile(profile) || profile === 'creative'; } function sameAgent(left: string | null | undefined, right: string | null | undefined): boolean { @@ -213,14 +209,6 @@ export const HarnessProfileSelector: React.FC = ({ }, [close, open]); const handleSelectProfile = useCallback((profileId: KnownHarnessProfileId) => { - if (isProfileInDevelopment(profileId)) { - const name = t(`chatInput.harness.profiles.${profileId}.name`); - notificationService.info(t('chatInput.harness.comingSoonNotice', { name }), { - duration: 3200, - }); - close(); - return; - } if (profileId === 'other') { setPage('agents'); return; @@ -411,13 +399,10 @@ export const HarnessProfileSelector: React.FC = ({ const name = t(`chatInput.harness.profiles.${id}.name`); const connected = !creatingNewSession && id === selectedProfile - && !legacySession - && !isProfileInDevelopment(id); + && !legacySession; const state = connected ? 'current' - : isProfileInDevelopment(id) - ? 'coming-soon' - : 'available'; + : 'available'; return ( ), - Input: ({ - disabled, - onChange, - onKeyDown, - placeholder, - value, - }: { - disabled?: boolean; - onChange?: React.ChangeEventHandler; - onKeyDown?: React.KeyboardEventHandler; - placeholder?: string; - value?: string; - }) => ( - - ), Modal: ({ children, closeOnOverlayClick, @@ -119,6 +97,10 @@ vi.mock('@/component-library', () => ({ }, })); +vi.mock('@/component-library', () => ({ + Alert: ({ message }: { message: string }) =>
{message}
, +})); + function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; diff --git a/src/web-ui/src/features/dispatch/DispatchResultDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchResultDialog.test.tsx index ec21bffdae..cde2f34532 100644 --- a/src/web-ui/src/features/dispatch/DispatchResultDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchResultDialog.test.tsx @@ -21,8 +21,7 @@ vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }), })); -vi.mock('@/component-library', () => ({ - Alert: ({ message }: { message: string }) =>
{message}
, +vi.mock('@bitfun/ui', () => ({ Button: ({ children, disabled, @@ -32,8 +31,13 @@ vi.mock('@/component-library', () => ({ {children} ), - Modal: ({ children, isOpen }: React.PropsWithChildren<{ isOpen: boolean }>) => - isOpen ?
{children}
: null, + Modal: ({ children, isOpen }: React.PropsWithChildren<{ isOpen: boolean }>) => ( + isOpen ?
{children}
: null + ), +})); + +vi.mock('@/component-library', () => ({ + Alert: ({ message }: { message: string }) =>
{message}
, })); const SYNCED = { diff --git a/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx b/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx index da39d83902..38a9370f82 100644 --- a/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx +++ b/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx @@ -45,14 +45,17 @@ vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ success: mocks.success, error: mocks.error }), })); -vi.mock('@/component-library', () => ({ - Avatar: ({ src, alt }: any) => {alt}, +vi.mock('@bitfun/ui', () => ({ Button: ({ children, ...props }: any) => , Modal: ({ isOpen, title, children }: any) => isOpen ? (
{children}
) : null, })); +vi.mock('@/component-library', () => ({ + Avatar: ({ src, alt }: any) => {alt}, +})); + describe('MarketAccountControls', () => { let container: HTMLDivElement; let root: ReturnType; diff --git a/src/web-ui/src/features/ssh-remote/PortForwardDialog.test.tsx b/src/web-ui/src/features/ssh-remote/PortForwardDialog.test.tsx index 71a05d4009..0a90741ad3 100644 --- a/src/web-ui/src/features/ssh-remote/PortForwardDialog.test.tsx +++ b/src/web-ui/src/features/ssh-remote/PortForwardDialog.test.tsx @@ -38,10 +38,10 @@ vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), })); -vi.mock('@/component-library', () => ({ - Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, - Modal: ({ isOpen, children }: React.PropsWithChildren<{ isOpen: boolean }>) => - isOpen ?
{children}
: null, +vi.mock('@bitfun/ui', () => ({ + Modal: ({ isOpen, children }: React.PropsWithChildren<{ isOpen: boolean }>) => ( + isOpen ?
{children}
: null + ), Button: ({ children, onClick, @@ -95,6 +95,10 @@ vi.mock('@/component-library', () => ({ data-testid={testId} /> )), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, Checkbox: ({ checked, onChange, diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx index 87212e26f7..1de89cb504 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx @@ -53,8 +53,7 @@ vi.mock('./SSHAuthPromptDialog', () => ({ SSHAuthPromptDialog: () => null, })); -vi.mock('@/component-library', () => ({ - Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, +vi.mock('@bitfun/ui', () => ({ Modal: ({ isOpen, children, @@ -121,6 +120,31 @@ vi.mock('@/component-library', () => ({ {suffix} ), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, + Input: ({ + label, + value, + onChange, + className, + placeholder, + suffix, + }: { + label?: string; + value?: string; + onChange?: React.ChangeEventHandler; + className?: string; + placeholder?: string; + suffix?: React.ReactNode; + }) => ( + + ), Select: ({ options, value, diff --git a/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.test.tsx b/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.test.tsx index 063b9c5865..b3b055f0b3 100644 --- a/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.test.tsx +++ b/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.test.tsx @@ -18,7 +18,7 @@ vi.mock('react-i18next', async () => { }; }); -vi.mock('@/component-library', () => ({ +vi.mock('@bitfun/ui', () => ({ Button: ({ children, onClick, @@ -26,24 +26,6 @@ vi.mock('@/component-library', () => ({ children: React.ReactNode; onClick?: () => void; }) => , - Checkbox: ({ - checked, - label, - onChange, - }: { - checked: boolean; - label: string; - onChange: (event: React.ChangeEvent) => void; - }) => ( - - ), Modal: ({ ariaLabel, children, @@ -53,6 +35,9 @@ vi.mock('@/component-library', () => ({ children: React.ReactNode; isOpen: boolean; }) => (isOpen ?
{children}
: null), +})); + +vi.mock('@/component-library', () => ({ PopupCloseButton: ({ 'aria-label': ariaLabel, onClick, diff --git a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx index 1ce2904ed3..39218e78da 100644 --- a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx +++ b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx @@ -228,7 +228,7 @@ export const HarnessProfileSelector: React.FC = ({ void onSelectProfile(profileId); } close(); - }, [close, fixedSession, onSelectProfile, onStartNewSession, selectedProfile, t]); + }, [close, fixedSession, onSelectProfile, onStartNewSession, selectedProfile]); const handleSelectAgent = useCallback((agent: HarnessAgentOption) => { if (agent.available === false) { diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx index 606b94e2b0..4aa6b27379 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx @@ -38,21 +38,24 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); -vi.mock('@/component-library', () => ({ +vi.mock('@bitfun/ui', () => ({ Button: ({ children, isLoading: _isLoading, iconOnly: _iconOnly, ...props }: any) => ( ), Modal: ({ isOpen, title, titleExtra, children }: any) => isOpen ? (
{titleExtra}{children}
) : null, - Search: ({ value, onChange, onSearch, inputAriaLabel }: any) => ( + SearchField: ({ value, onValueChange, onSearch, 'aria-label': ariaLabel }: any) => ( onChange(event.target.value)} + onChange={event => onValueChange(event.target.value)} onKeyDown={event => event.key === 'Enter' && onSearch(event.currentTarget.value)} /> ), +})); + +vi.mock('@/component-library', () => ({ Select: () =>
, Input: ({ label, ...props }: any) => , Textarea: ({ label, showCount: _showCount, ...props }: any) => ( diff --git a/src/web-ui/src/infrastructure/config/components/GlobalPermissionRulesDialog.test.tsx b/src/web-ui/src/infrastructure/config/components/GlobalPermissionRulesDialog.test.tsx index 01807afa53..0c01926060 100644 --- a/src/web-ui/src/infrastructure/config/components/GlobalPermissionRulesDialog.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/GlobalPermissionRulesDialog.test.tsx @@ -33,8 +33,7 @@ vi.mock('react-i18next', () => ({ }), })); -vi.mock('@/component-library', () => ({ - Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, +vi.mock('@bitfun/ui', () => ({ Modal: ({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) => ( isOpen ?
{children}
: null ), @@ -53,6 +52,18 @@ vi.mock('@/component-library', () => ({ }) => ( ), + Input: ({ value, disabled, onChange, 'aria-label': ariaLabel }: { + value: string; + disabled?: boolean; + onChange?: (event: React.ChangeEvent) => void; + 'aria-label'?: string; + }) => ( + + ), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, Select: ({ value, options, disabled, onChange, 'aria-label': ariaLabel }: { value: string; options: Array<{ value: string; label: string }>; @@ -69,14 +80,6 @@ vi.mock('@/component-library', () => ({ {options.map((option) => )} ), - Input: ({ value, disabled, onChange, 'aria-label': ariaLabel }: { - value: string; - disabled?: boolean; - onChange?: (event: React.ChangeEvent) => void; - 'aria-label'?: string; - }) => ( - - ), })); describe('GlobalPermissionRulesDialog', () => { diff --git a/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx index 38701cf1eb..3e2e8927d2 100644 --- a/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx @@ -26,11 +26,10 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: translateMock }), })); -vi.mock('@/component-library', () => ({ +vi.mock('@bitfun/ui', () => ({ Button: ({ children, disabled, onClick }: React.ButtonHTMLAttributes) => ( ), - ConfigPageLoading: ({ text }: { text: string }) =>
{text}
, Switch: ({ checked, disabled, onChange }: React.InputHTMLAttributes) => ( ), @@ -39,6 +38,10 @@ vi.mock('@/component-library', () => ({ isOpen: boolean; title?: string; }) => (isOpen ?
{children}
: null), +})); + +vi.mock('@/component-library', () => ({ + ConfigPageLoading: ({ text }: { text: string }) =>
{text}
, ConfirmDialog: ({ confirmText, isOpen, message, onConfirm, title }: { confirmText?: string; isOpen: boolean; diff --git a/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.test.tsx index 5ee5a41ec7..9741ac2023 100644 --- a/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.test.tsx @@ -33,44 +33,41 @@ vi.mock('@/infrastructure/i18n', () => ({ }), })); -vi.mock('@/component-library', () => ({ - Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, - ConfigPageLoading: ({ text }: { text?: string }) =>
{text}
, - ConfigPageMessage: ({ - message, - }: { - message: { type: string; text: string } | null; - }) => message ? ( -
{message.text}
- ) : null, - ConfigPageRefreshButton: () => , + icon?: React.ReactNode; + }) => , Input: ({ - prefix, - suffix, - inputSize: _inputSize, + leading, + trailing, ...props }: React.InputHTMLAttributes & { - prefix?: React.ReactNode; - suffix?: React.ReactNode; - inputSize?: string; + leading?: React.ReactNode; + trailing?: React.ReactNode; }) => (
- {prefix} + {leading} - {suffix} + {trailing}
), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, + ConfigPageLoading: ({ text }: { text?: string }) =>
{text}
, + ConfigPageMessage: ({ + message, + }: { + message: { type: string; text: string } | null; + }) => message ? ( +
{message.text}
+ ) : null, + ConfigPageRefreshButton: () =>