From b90bf512d770ccfc5c6a25eb3e09600b4cbdacaa Mon Sep 17 00:00:00 2001 From: wsp Date: Thu, 8 Oct 2026 19:45:07 +0800 Subject: [PATCH] feat(assistants): Add close and reopen controls Allow assistants to close from the sidebar and gallery after cancelling running sessions, while preserving their configuration and history. Keep closed assistants in the catalog across restarts and provide a reopen action. Show new-session and close controls only for open assistants, and disable closing on hosts without catalog support. Cover lifecycle persistence, legacy snapshots, and device switching; update operation registries, capability catalogs, and localization. --- docs/interactive-capabilities/README.md | 16 +- .../capabilities.json | 66 ++++++- .../feature.personal-assistants.md | 2 + .../technical/product-control-open-audit.json | 26 ++- .../technical/tauri-command-map.json | 32 +++- src/apps/desktop/src/api/commands.rs | 22 +++ src/apps/desktop/src/lib.rs | 1 + src/crates/assembly/core/AGENTS-CN.md | 3 + src/crates/assembly/core/AGENTS.md | 3 + .../core/src/service/workspace/service.rs | 168 +++++++++++++++++- .../generated/product-control-catalog.json | 66 ++++++- .../generated/remote-surface-registry.json | 14 +- .../src/remote_surface/table.rs | 1 + .../interactive-capabilities/catalog.json | 18 ++ .../sections/workspaces/WorkspaceItem.tsx | 11 ++ .../generated/interactive-capabilities.json | 66 ++++++- .../app/scenes/assistant/AssistantScene.tsx | 2 +- .../scenes/profile/views/AssistantCard.tsx | 46 ++++- .../profile/views/AssistantConfigPage.tsx | 2 +- .../scenes/profile/views/NurseryGallery.tsx | 42 ++++- .../src/flow_chat/store/FlowChatStore.test.ts | 56 ++++++ .../src/flow_chat/store/FlowChatStore.ts | 5 +- .../api/generated/productControl.ts | 2 +- .../api/generated/remoteSurface.ts | 2 +- .../api/service-api/GlobalAPI.ts | 14 ++ .../contexts/WorkspaceContext.ts | 1 + .../contexts/WorkspaceProvider.tsx | 8 + .../business/workspaceManager.test.ts | 165 +++++++++++++++++ .../services/business/workspaceManager.ts | 53 +++++- src/web-ui/src/locales/en-US/common.json | 2 + .../src/locales/en-US/scenes/profile.json | 4 + src/web-ui/src/locales/zh-CN/common.json | 2 + .../src/locales/zh-CN/scenes/profile.json | 4 + src/web-ui/src/locales/zh-TW/common.json | 2 + .../src/locales/zh-TW/scenes/profile.json | 4 + .../src/shared/types/global-state.test.ts | 20 +++ src/web-ui/src/shared/types/global-state.ts | 7 + 37 files changed, 905 insertions(+), 53 deletions(-) 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={