diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 5bcea293cb..dbf962b8b6 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**324 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 +OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**325 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 -OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **324** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. +OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **325** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -27,20 +27,20 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **669** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **670** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **669** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **670** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **49**、委托 **62**、需交互 **213**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **49**、委托 **62**、需交互 **214**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;智能体不接触原始 Tauri Command。 -- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 324 项子能力都不会写入 system prompt。 +- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 325 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读智能体只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **49 direct**, **62 delegated**, **213 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **49 direct**, **62 delegated**, **214 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 324 documented items enters the system prompt. +- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 325 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 2c3be0e77f..6eacd2da54 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "f965bcc92d234b79484b5ec6a2fbd817c40e4c78119dd41b804fbbce3876fa5a", + "digest": "e05dbc222e1267523832f82d6392701ab011c9d8d5ad1582dbe407904dc80d3d", "ownerDigest": "2269c2432e5db44706b2bce44df3c60672141a47c36141aeb1257ea7fd2507f8", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 324, + "documentedItems": 325, "controlCoverage": { "direct": 49, "delegated": 62, - "interactive": 213, + "interactive": 214, "unsupported": 0 } }, @@ -1418,6 +1418,7 @@ "capabilityId": "feature.personal-assistants", "itemIds": [ "assistant-gallery", + "assistant-open-close", "primary-delete-reset", "identity-avatar", "persona-documents", @@ -1513,6 +1514,52 @@ "sceneId": "profile" } }, + { + "id": "feature.personal-assistants:open:assistant-open-close", + "capabilityId": "feature.personal-assistants", + "itemIds": [ + "assistant-open-close" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "visualSelection", + "presentationTarget": { + "kind": "scene", + "sceneId": "profile" + } + }, { "id": "feature.personal-assistants:open:primary-delete-reset", "capabilityId": "feature.personal-assistants", @@ -19965,6 +20012,17 @@ "reasonEn": "“Browse existing assistants, create a new one, and review each assistant's identity and capability summary” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "assistant-open-close", + "titleZh": "关闭侧栏中的助理并取消正在运行的会话,保留配置和历史,之后从助理空间重新打开", + "titleEn": "Close sidebar assistants and cancel running sessions, preserve configuration and history, and reopen them from the assistant gallery", + "control": { + "kind": "open", + "reasonCode": "visualSelection", + "reasonZh": "关闭操作依赖用户选择的助理;助理空间保留完整目录,并提供重新打开入口。", + "reasonEn": "Closing depends on the assistant selected by the user; the gallery retains the complete catalog and provides a reopen action." + } + }, { "id": "primary-delete-reset", "titleZh": "设为主助理、删除非主助理,或将助理工作区重置为默认状态", @@ -20123,6 +20181,8 @@ "Configure default Skills, built-in tools, and MCP tools", "浏览已有助理、创建新助理并查看每个助理的身份与能力概览", "Browse existing assistants, create a new one, and review each assistant's identity and capability summary", + "关闭侧栏中的助理并取消正在运行的会话,保留配置和历史,之后从助理空间重新打开", + "Close sidebar assistants and cancel running sessions, preserve configuration and history, and reopen them from the assistant gallery", "设为主助理、删除非主助理,或将助理工作区重置为默认状态", "Set the primary assistant, delete non-primary assistants, or reset an assistant workspace to defaults", "编辑助理名称、角色类型和气质,并选择官方头像、Emoji 或自定义头像字符", diff --git a/docs/interactive-capabilities/capabilities/feature.personal-assistants.md b/docs/interactive-capabilities/capabilities/feature.personal-assistants.md index 47197eed09..98edcef4f4 100644 --- a/docs/interactive-capabilities/capabilities/feature.personal-assistants.md +++ b/docs/interactive-capabilities/capabilities/feature.personal-assistants.md @@ -19,6 +19,8 @@ Create and manage persistent AI assistants, define their identity and persona, c - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 浏览已有助理、创建新助理并查看每个助理的身份与能力概览 - Browse existing assistants, create a new one, and review each assistant's identity and capability summary +- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 关闭侧栏中的助理并取消正在运行的会话,保留配置和历史,之后从助理空间重新打开 + - Close sidebar assistants and cancel running sessions, preserve configuration and history, and reopen them from the assistant gallery - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 设为主助理、删除非主助理,或将助理工作区重置为默认状态 - Set the primary assistant, delete non-primary assistants, or reset an assistant workspace to defaults - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 编辑助理名称、角色类型和气质,并选择官方头像、Emoji 或自定义头像字符 diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index 7a668e4f4e..fd51f91eae 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,13 +1,13 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "f965bcc92d234b79484b5ec6a2fbd817c40e4c78119dd41b804fbbce3876fa5a", - "count": 213, + "catalogDigest": "e05dbc222e1267523832f82d6392701ab011c9d8d5ad1582dbe407904dc80d3d", + "count": 214, "reasonCounts": { "externalAuth": 4, "secretEntry": 5, "unstructuredInteraction": 185, - "visualSelection": 19 + "visualSelection": 20 }, "entries": [ { @@ -353,9 +353,29 @@ }, "evidence": [ "command:create_assistant_workspace", + "command:get_assistant_workspaces", "source:src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx#NurseryGallery" ] }, + { + "capabilityId": "feature.personal-assistants", + "itemId": "assistant-open-close", + "titleZh": "关闭侧栏中的助理并取消正在运行的会话,保留配置和历史,之后从助理空间重新打开", + "titleEn": "Close sidebar assistants and cancel running sessions, preserve configuration and history, and reopen them from the assistant gallery", + "reasonCode": "visualSelection", + "reasonZh": "关闭操作依赖用户选择的助理;助理空间保留完整目录,并提供重新打开入口。", + "reasonEn": "Closing depends on the assistant selected by the user; the gallery retains the complete catalog and provides a reopen action.", + "presentationTarget": { + "kind": "scene", + "sceneId": "profile" + }, + "evidence": [ + "command:close_workspace", + "command:open_workspace", + "source:src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx#handleCloseWorkspace", + "source:src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx#handleOpenAssistant" + ] + }, { "capabilityId": "feature.personal-assistants", "itemId": "primary-delete-reset", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index a380cf6484..f51bbba056 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,11 +1,11 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "f965bcc92d234b79484b5ec6a2fbd817c40e4c78119dd41b804fbbce3876fa5a", - "commandCount": 669, + "catalogDigest": "e05dbc222e1267523832f82d6392701ab011c9d8d5ad1582dbe407904dc80d3d", + "commandCount": 670, "coverage": { - "commandCount": 669, - "documentedCommandCount": 621, + "commandCount": 670, + "documentedCommandCount": 622, "implementationCommandCount": 48, "implementationDigest": "f6d38a24a70708988cb47ada81d07eccf0668684e8734c9ee5155b9ffa7e3db8" }, @@ -1285,11 +1285,13 @@ { "id": "close_workspace", "moduleId": "commands", - "capabilityId": "feature.projects", + "capabilityId": null, "capabilityIds": [ + "feature.personal-assistants", "feature.projects" ], "documentedItemIds": [ + "feature.personal-assistants:assistant-open-close", "feature.projects:close-switch-reorder" ], "visibility": "documented", @@ -2792,6 +2794,22 @@ "signature": "fn get_app_version( app: AppHandle, request: GetAppVersionRequest, ) -> Result", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "get_assistant_workspaces", + "moduleId": "commands", + "capabilityId": "feature.personal-assistants", + "capabilityIds": [ + "feature.personal-assistants" + ], + "documentedItemIds": [ + "feature.personal-assistants:assistant-gallery" + ], + "visibility": "documented", + "rustPath": "get_assistant_workspaces", + "sourceFile": "src/apps/desktop/src/api/commands.rs", + "signature": "fn get_assistant_workspaces( state: State<'_, AppState>, ) -> Result, String>", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "get_available_modes", "moduleId": "agentic", @@ -6361,11 +6379,13 @@ { "id": "open_workspace", "moduleId": "commands", - "capabilityId": "feature.projects", + "capabilityId": null, "capabilityIds": [ + "feature.personal-assistants", "feature.projects" ], "documentedItemIds": [ + "feature.personal-assistants:assistant-open-close", "feature.projects:open-create" ], "visibility": "documented", diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 85496af0d7..a84d48e97c 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -40,6 +40,7 @@ struct WorkspaceStateSnapshot { current_workspace: Option, recent_workspaces: Vec, opened_workspaces: Vec, + assistant_workspaces: Vec, primary_assistant_workspace_id: Option, legacy_remote_workspace: Option, } @@ -51,6 +52,7 @@ pub struct WorkspaceStartupStateSnapshotDto { pub current_workspace: Option, pub recent_workspaces: Vec, pub opened_workspaces: Vec, + pub assistant_workspaces: Vec, pub primary_assistant_workspace_id: Option, pub legacy_remote_workspace: Option, } @@ -1979,6 +1981,12 @@ async fn collect_workspace_state_snapshot(state: &State<'_, AppState>) -> Worksp current_workspace, recent_workspaces, opened_workspaces, + assistant_workspaces: workspace_service + .get_assistant_workspaces() + .await + .iter() + .map(WorkspaceInfoDto::from_workspace_info) + .collect(), primary_assistant_workspace_id, legacy_remote_workspace, } @@ -2096,6 +2104,7 @@ async fn initialize_workspace_startup_state_impl( current_workspace: snapshot.current_workspace, recent_workspaces: snapshot.recent_workspaces, opened_workspaces: snapshot.opened_workspaces, + assistant_workspaces: snapshot.assistant_workspaces, primary_assistant_workspace_id: snapshot.primary_assistant_workspace_id, legacy_remote_workspace: snapshot.legacy_remote_workspace, }) @@ -2169,6 +2178,19 @@ async fn cleanup_invalid_workspaces_impl( } } +#[tauri::command] +pub async fn get_assistant_workspaces( + state: State<'_, AppState>, +) -> Result, String> { + Ok(state + .workspace_service + .get_assistant_workspaces() + .await + .iter() + .map(WorkspaceInfoDto::from_workspace_info) + .collect()) +} + #[tauri::command] pub async fn get_opened_workspaces( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 11da1faf39..f0739d81b0 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1659,6 +1659,7 @@ pub async fn run() { remove_recent_workspace, cleanup_invalid_workspaces, get_opened_workspaces, + get_assistant_workspaces, open_workspace, open_remote_workspace, create_assistant_workspace, diff --git a/src/crates/assembly/core/AGENTS-CN.md b/src/crates/assembly/core/AGENTS-CN.md index 07a08c41fc..87d1b4bec2 100644 --- a/src/crates/assembly/core/AGENTS-CN.md +++ b/src/crates/assembly/core/AGENTS-CN.md @@ -117,3 +117,6 @@ cargo test -p openbitfun-core --no-default-features --features Vec { let manager = self.manager.read().await; - manager + let mut workspaces: Vec<_> = manager .get_workspaces() .values() .filter(|workspace| workspace.workspace_kind == WorkspaceKind::Assistant) .cloned() - .collect() + .collect(); + workspaces.sort_by(|left, right| { + (&left.assistant_id, &left.id).cmp(&(&right.assistant_id, &right.id)) + }); + workspaces } /// Returns the assistant workspace currently assigned the primary role. @@ -2168,6 +2172,7 @@ impl WorkspaceService { async fn ensure_assistant_workspaces(&self) -> OpenBitFunResult<()> { let descriptors = self.discover_assistant_workspaces().await?; + let known_assistants = self.get_assistant_workspaces().await; let has_current_workspace = self.get_current_workspace().await.is_some(); let has_opened_remote = { let manager = self.manager.read().await; @@ -2197,8 +2202,13 @@ impl WorkspaceService { // If a remote workspace tab exists but nothing is current yet (e.g. pending SSH // reconnect), do not auto-activate the default assistant workspace — that would look // like a spurious new local workspace. - let should_activate = - !has_current_workspace && !has_opened_remote && index == activation_index; + // Discovery refreshes the catalog, not the user's opened list. Only + // a fresh install selects an assistant automatically; an empty opened + // list on an existing install can mean the user closed every assistant. + let should_activate = known_assistants.is_empty() + && !has_current_workspace + && !has_opened_remote + && index == activation_index; let options = WorkspaceCreateOptions { auto_set_current: should_activate, add_to_recent: false, @@ -2208,8 +2218,17 @@ impl WorkspaceService { ..Default::default() }; - self.open_workspace_with_options(descriptor.path, options) + if should_activate { + self.open_workspace_with_options(descriptor.path, options) + .await?; + } else { + self.track_workspace_activity( + descriptor.path, + options, + WorkspaceActivityMode::RefreshMetadata, + ) .await?; + } } { @@ -2489,6 +2508,145 @@ mod tests { assert!(!service.is_primary_assistant_workspace(&built_in.id).await); } + #[tokio::test] + async fn assistant_close_preserves_catalog_and_closed_state_after_restart() { + let env = TestEnvironment::new(); + let service = build_test_workspace_service(env.path_manager.clone()).await; + service.ensure_assistant_workspaces().await.unwrap(); + let built_in = service.get_primary_assistant_workspace().await.unwrap(); + let named = service + .create_assistant_workspace(Some("closed-assistant".to_string())) + .await + .unwrap(); + service + .set_primary_assistant_workspace(&named.id) + .await + .unwrap(); + let history_marker = named.root_path.join("history-marker.txt"); + fs::write(&history_marker, "preserved").await.unwrap(); + service.close_workspace(&named.id).await.unwrap(); + service.close_workspace(&built_in.id).await.unwrap(); + + let restored = build_test_workspace_service(env.path_manager.clone()).await; + restored.load_workspace_history_only().await.unwrap(); + restored.ensure_assistant_workspaces().await.unwrap(); + // Repeated discovery/cleanup must not reopen an explicitly closed row. + restored.ensure_assistant_workspaces().await.unwrap(); + assert!(restored.get_opened_workspaces().await.is_empty()); + assert!(restored.get_current_workspace().await.is_none()); + assert_eq!(restored.get_assistant_workspaces().await.len(), 2); + assert_eq!( + restored.get_primary_assistant_workspace().await.unwrap().id, + named.id + ); + assert_eq!( + fs::read_to_string(&history_marker).await.unwrap(), + "preserved" + ); + + let reopened = restored.open_workspace_by_id(&named.id).await.unwrap(); + assert_eq!(reopened.id, named.id); + assert_eq!(restored.get_opened_workspaces().await.len(), 1); + assert_eq!(restored.get_current_workspace().await.unwrap().id, named.id); + } + + #[tokio::test] + async fn assistant_discovery_preserves_existing_opened_order_and_selection() { + let env = TestEnvironment::new(); + let service = build_test_workspace_service(env.path_manager.clone()).await; + service.ensure_assistant_workspaces().await.unwrap(); + let built_in = service.get_primary_assistant_workspace().await.unwrap(); + let named = service + .create_assistant_workspace(Some("named".into())) + .await + .unwrap(); + let second = service + .create_assistant_workspace(Some("second".into())) + .await + .unwrap(); + service.set_active_workspace(&named.id).await.unwrap(); + service + .reorder_opened_workspaces(vec![ + built_in.id.clone(), + second.id.clone(), + named.id.clone(), + ]) + .await + .unwrap(); + service.close_workspace(&built_in.id).await.unwrap(); + service.ensure_assistant_workspaces().await.unwrap(); + assert_eq!( + service + .get_opened_workspaces() + .await + .iter() + .map(|workspace| &workspace.id) + .collect::>(), + vec![&second.id, &named.id] + ); + assert_eq!(service.get_current_workspace().await.unwrap().id, named.id); + assert_eq!( + service.get_primary_assistant_workspace().await.unwrap().id, + built_in.id + ); + } + + #[tokio::test] + async fn assistant_catalog_accepts_legacy_persistence_without_primary_selection() { + let env = TestEnvironment::new(); + let service = build_test_workspace_service(env.path_manager.clone()).await; + service.ensure_assistant_workspaces().await.unwrap(); + let built_in = service.get_primary_assistant_workspace().await.unwrap(); + let named = service + .create_assistant_workspace(Some("legacy".into())) + .await + .unwrap(); + let mut payload: serde_json::Value = service + .persistence + .load_json("workspace_data") + .await + .unwrap() + .unwrap(); + payload + .as_object_mut() + .unwrap() + .remove("primary_assistant_key"); + payload + .as_object_mut() + .unwrap() + .remove("recent_assistant_workspaces"); + service + .persistence + .save_json("workspace_data", &payload, StorageOptions::default()) + .await + .unwrap(); + + let restored = build_test_workspace_service(env.path_manager.clone()).await; + restored.load_workspace_history_only().await.unwrap(); + restored.ensure_assistant_workspaces().await.unwrap(); + assert_eq!( + restored + .get_opened_workspaces() + .await + .iter() + .map(|workspace| &workspace.id) + .collect::>(), + vec![&named.id, &built_in.id] + ); + assert_eq!(restored.get_current_workspace().await.unwrap().id, named.id); + assert_eq!( + restored.get_primary_assistant_workspace().await.unwrap().id, + built_in.id + ); + let saved: WorkspacePersistenceData = restored + .persistence + .load_json("workspace_data") + .await + .unwrap() + .unwrap(); + assert_eq!(saved.opened_workspace_ids, vec![named.id, built_in.id]); + } + #[tokio::test] async fn deleted_built_in_assistant_is_not_recreated_when_named_assistant_exists() { let env = TestEnvironment::new(); diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 53c968faba..954e70afd1 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "f965bcc92d234b79484b5ec6a2fbd817c40e4c78119dd41b804fbbce3876fa5a", + "digest": "e05dbc222e1267523832f82d6392701ab011c9d8d5ad1582dbe407904dc80d3d", "ownerDigest": "2269c2432e5db44706b2bce44df3c60672141a47c36141aeb1257ea7fd2507f8", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 324, + "documentedItems": 325, "controlCoverage": { "direct": 49, "delegated": 62, - "interactive": 213, + "interactive": 214, "unsupported": 0 } }, @@ -1418,6 +1418,7 @@ "capabilityId": "feature.personal-assistants", "itemIds": [ "assistant-gallery", + "assistant-open-close", "primary-delete-reset", "identity-avatar", "persona-documents", @@ -1513,6 +1514,52 @@ "sceneId": "profile" } }, + { + "id": "feature.personal-assistants:open:assistant-open-close", + "capabilityId": "feature.personal-assistants", + "itemIds": [ + "assistant-open-close" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "visualSelection", + "presentationTarget": { + "kind": "scene", + "sceneId": "profile" + } + }, { "id": "feature.personal-assistants:open:primary-delete-reset", "capabilityId": "feature.personal-assistants", @@ -19969,6 +20016,17 @@ "reasonEn": "“Browse existing assistants, create a new one, and review each assistant's identity and capability summary” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "assistant-open-close", + "titleZh": "关闭侧栏中的助理并取消正在运行的会话,保留配置和历史,之后从助理空间重新打开", + "titleEn": "Close sidebar assistants and cancel running sessions, preserve configuration and history, and reopen them from the assistant gallery", + "control": { + "kind": "open", + "reasonCode": "visualSelection", + "reasonZh": "关闭操作依赖用户选择的助理;助理空间保留完整目录,并提供重新打开入口。", + "reasonEn": "Closing depends on the assistant selected by the user; the gallery retains the complete catalog and provides a reopen action." + } + }, { "id": "primary-delete-reset", "titleZh": "设为主助理、删除非主助理,或将助理工作区重置为默认状态", @@ -20127,6 +20185,8 @@ "Configure default Skills, built-in tools, and MCP tools", "浏览已有助理、创建新助理并查看每个助理的身份与能力概览", "Browse existing assistants, create a new one, and review each assistant's identity and capability summary", + "关闭侧栏中的助理并取消正在运行的会话,保留配置和历史,之后从助理空间重新打开", + "Close sidebar assistants and cancel running sessions, preserve configuration and history, and reopen them from the assistant gallery", "设为主助理、删除非主助理,或将助理工作区重置为默认状态", "Set the primary assistant, delete non-primary assistants, or reset an assistant workspace to defaults", "编辑助理名称、角色类型和气质,并选择官方头像、Emoji 或自定义头像字符", diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index 2b5d45d4e5..e0211eedd0 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:dcb2b9d52e3f978f", + "digest": "fnv1a64:7a6e4c114b970186", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -2287,6 +2287,18 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "get_assistant_workspaces", + "surface": "tauri_command", + "remoteWorkspace": "WorkspaceAgnostic", + "peer": { + "kind": "proxied" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the CLI peer host has no handler for this command" + } + }, { "id": "get_available_modes", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index bad40f876a..026a4582dd 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -272,6 +272,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("get_announcement_tips", Agnostic, ControllerLocal, REFUSED), op("get_app_state", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("get_app_version", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), + op("get_assistant_workspaces", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("get_available_modes", Unaudited, Proxied, HANDLED), op("get_available_tools", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("get_baseline_snapshot_diff", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index aef7ab5965..0006c7e673 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -930,9 +930,27 @@ }, "evidence": [ "command:create_assistant_workspace", + "command:get_assistant_workspaces", "source:src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx#NurseryGallery" ] }, + { + "id": "assistant-open-close", + "titleZh": "关闭侧栏中的助理并取消正在运行的会话,保留配置和历史,之后从助理空间重新打开", + "titleEn": "Close sidebar assistants and cancel running sessions, preserve configuration and history, and reopen them from the assistant gallery", + "control": { + "kind": "open", + "reasonCode": "visualSelection", + "reasonZh": "关闭操作依赖用户选择的助理;助理空间保留完整目录,并提供重新打开入口。", + "reasonEn": "Closing depends on the assistant selected by the user; the gallery retains the complete catalog and provides a reopen action." + }, + "evidence": [ + "command:close_workspace", + "command:open_workspace", + "source:src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx#handleCloseWorkspace", + "source:src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx#handleOpenAssistant" + ] + }, { "id": "primary-delete-reset", "titleZh": "设为主助理、删除非主助理,或将助理工作区重置为默认状态", diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 605a28d70d..638ba5d1d8 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -108,6 +108,7 @@ const WorkspaceItem: React.FC = ({ const { setActiveWorkspace, closeWorkspaceById, + assistantWorkspaces, deleteAssistantWorkspace, primaryAssistantWorkspaceId, resetAssistantWorkspace, @@ -956,8 +957,18 @@ const WorkspaceItem: React.FC = ({ > {t('nav.sessions.manage')} + } + onClick={() => { void handleCloseWorkspace(); }} + disabled={assistantWorkspaces === null} + title={assistantWorkspaces === null ? t('nav.workspaces.closeAssistantUnsupported') : undefined} + data-testid="nav-workspace-menu-close-assistant" + > + {t('nav.workspaces.actions.closeAssistant')} + {(isDefaultAssistantWorkspace || isDeletableAssistantWorkspace) ? ( <> + {isDefaultAssistantWorkspace ? ( } diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 2c3be0e77f..6eacd2da54 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "f965bcc92d234b79484b5ec6a2fbd817c40e4c78119dd41b804fbbce3876fa5a", + "digest": "e05dbc222e1267523832f82d6392701ab011c9d8d5ad1582dbe407904dc80d3d", "ownerDigest": "2269c2432e5db44706b2bce44df3c60672141a47c36141aeb1257ea7fd2507f8", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 324, + "documentedItems": 325, "controlCoverage": { "direct": 49, "delegated": 62, - "interactive": 213, + "interactive": 214, "unsupported": 0 } }, @@ -1418,6 +1418,7 @@ "capabilityId": "feature.personal-assistants", "itemIds": [ "assistant-gallery", + "assistant-open-close", "primary-delete-reset", "identity-avatar", "persona-documents", @@ -1513,6 +1514,52 @@ "sceneId": "profile" } }, + { + "id": "feature.personal-assistants:open:assistant-open-close", + "capabilityId": "feature.personal-assistants", + "itemIds": [ + "assistant-open-close" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "visualSelection", + "presentationTarget": { + "kind": "scene", + "sceneId": "profile" + } + }, { "id": "feature.personal-assistants:open:primary-delete-reset", "capabilityId": "feature.personal-assistants", @@ -19965,6 +20012,17 @@ "reasonEn": "“Browse existing assistants, create a new one, and review each assistant's identity and capability summary” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "assistant-open-close", + "titleZh": "关闭侧栏中的助理并取消正在运行的会话,保留配置和历史,之后从助理空间重新打开", + "titleEn": "Close sidebar assistants and cancel running sessions, preserve configuration and history, and reopen them from the assistant gallery", + "control": { + "kind": "open", + "reasonCode": "visualSelection", + "reasonZh": "关闭操作依赖用户选择的助理;助理空间保留完整目录,并提供重新打开入口。", + "reasonEn": "Closing depends on the assistant selected by the user; the gallery retains the complete catalog and provides a reopen action." + } + }, { "id": "primary-delete-reset", "titleZh": "设为主助理、删除非主助理,或将助理工作区重置为默认状态", @@ -20123,6 +20181,8 @@ "Configure default Skills, built-in tools, and MCP tools", "浏览已有助理、创建新助理并查看每个助理的身份与能力概览", "Browse existing assistants, create a new one, and review each assistant's identity and capability summary", + "关闭侧栏中的助理并取消正在运行的会话,保留配置和历史,之后从助理空间重新打开", + "Close sidebar assistants and cancel running sessions, preserve configuration and history, and reopen them from the assistant gallery", "设为主助理、删除非主助理,或将助理工作区重置为默认状态", "Set the primary assistant, delete non-primary assistants, or reset an assistant workspace to defaults", "编辑助理名称、角色类型和气质,并选择官方头像、Emoji 或自定义头像字符", diff --git a/src/web-ui/src/app/scenes/assistant/AssistantScene.tsx b/src/web-ui/src/app/scenes/assistant/AssistantScene.tsx index 929dfe20ca..4940ca989f 100644 --- a/src/web-ui/src/app/scenes/assistant/AssistantScene.tsx +++ b/src/web-ui/src/app/scenes/assistant/AssistantScene.tsx @@ -13,7 +13,7 @@ const AssistantScene: React.FC = () => { const { t } = useI18n('common'); const selectedAssistantWorkspaceId = useMyAgentStore((s) => s.selectedAssistantWorkspaceId); const setSelectedAssistantWorkspaceId = useMyAgentStore((s) => s.setSelectedAssistantWorkspaceId); - const { currentWorkspace, assistantWorkspacesList, primaryAssistantWorkspaceId } = useWorkspaceContext(); + const { currentWorkspace, allAssistantWorkspacesList: assistantWorkspacesList, primaryAssistantWorkspaceId } = useWorkspaceContext(); const activeAssistantWorkspace = currentWorkspace?.workspaceKind === WorkspaceKind.Assistant ? currentWorkspace : null; diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx index feab09edff..ec8caa529b 100644 --- a/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx +++ b/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx @@ -20,6 +20,11 @@ interface AssistantCardProps { workspace: WorkspaceInfo; onClick: () => void; onNewSession?: () => void; + onOpen?: () => void; + isOpening?: boolean; + onClose?: () => void; + isClosing?: boolean; + closeDisabledReason?: string; onDelete?: () => void; onSetPrimary?: () => void; isPrimary?: boolean; @@ -33,6 +38,11 @@ const AssistantCard: React.FC = ({ workspace, onClick, onNewSession, + onOpen, + isOpening = false, + onClose, + isClosing = false, + closeDisabledReason, onDelete, onSetPrimary, isPrimary, @@ -49,8 +59,8 @@ const AssistantCard: React.FC = ({ const emoji = identity?.emoji?.trim() ?? ''; const creature = identity?.creature?.trim() || ''; const vibe = identity?.vibe?.trim() || ''; - const isBusy = isDeleting || isStartingSession || isSettingPrimary; - const hasActions = Boolean(onNewSession || onSetPrimary || onDelete); + const isBusy = isDeleting || isStartingSession || isSettingPrimary || isOpening || isClosing; + const hasActions = Boolean(onOpen || onClose || onNewSession || onSetPrimary || onDelete); return (
= ({ className="assistant-card__main" onClick={onClick} aria-label={`${t('nursery.card.configure')}: ${name}`} - disabled={isDeleting || isSettingPrimary} + disabled={isDeleting || isSettingPrimary || isOpening || isClosing} /> = ({ {hasActions ? ( + {onOpen ? ( + + ) : null} {onNewSession ? ( ) : null} + {onClose ? ( + + ) : null} + {onSetPrimary ? ( @@ -164,7 +198,7 @@ const AssistantCard: React.FC = ({ onClick={onSetPrimary} aria-label={t('nursery.card.setPrimary')} loading={isSettingPrimary} - disabled={isDeleting || isStartingSession || isSettingPrimary} + disabled={isBusy} icon={ @@ -180,7 +214,7 @@ const AssistantCard: React.FC = ({ onClick={onDelete} aria-label={t('nursery.card.delete')} loading={isDeleting} - disabled={isDeleting || isStartingSession || isSettingPrimary} + disabled={isBusy} icon={