From e6c6383c01985fdaa16610bca276765bdbc8c18c Mon Sep 17 00:00:00 2001 From: GCWing Date: Fri, 30 Oct 2026 15:31:56 +0800 Subject: [PATCH 1/3] feat(web-ui): customize navigation and add session trajectory --- .../packages/ui/src/components/Icon/Icon.tsx | 3 + design-system/packages/ui/tests/icon.test.mjs | 5 +- .../product-customization-blueprint.md | 24 ++ docs/interactive-capabilities/README.md | 8 +- .../capabilities.json | 68 +-- .../feature.ecosystem-compatibility.md | 14 +- .../technical/product-control-open-audit.json | 46 +-- .../technical/tauri-command-map.json | 24 +- src/apps/cli/src/peer_host/commands/mod.rs | 2 + .../cli/src/peer_host/commands/session.rs | 72 +++- src/apps/desktop/Cargo.toml | 2 +- src/apps/desktop/src/api/agentic_api.rs | 66 +++ src/apps/desktop/src/lib.rs | 1 + .../generated/product-control-catalog.json | 68 +-- .../generated/remote-surface-registry.json | 16 +- .../src/remote_surface/capabilities.rs | 5 + .../src/remote_surface/table.rs | 1 + src/crates/services/services-core/AGENTS.md | 1 + .../services/services-core/src/session/mod.rs | 1 + .../services-core/src/session/trajectory.rs | 348 ++++++++++++++++ .../interactive-capabilities/catalog.json | 15 +- src/web-ui/README.md | 50 ++- .../NavPanel/MainNav.projectCreation.test.tsx | 13 + .../src/app/components/NavPanel/MainNav.tsx | 37 +- .../src/app/components/NavPanel/NavPanel.scss | 34 +- .../src/app/components/NavPanel/appearance.ts | 1 - .../AppearanceQuickSwitchMenuItem.test.tsx | 165 -------- .../AppearanceQuickSwitchMenuItem.tsx | 301 -------------- .../components/PersistentFooterActions.tsx | 61 +-- .../components/PinnedNavigationItems.tsx | 45 ++ .../navigationIconIntegration.test.ts | 26 +- .../NavPanel/unifiedSessionCreation.test.ts | 34 +- .../components/panels/base/FlexiblePanel.tsx | 6 + .../src/app/components/panels/base/utils.ts | 1 + .../generated/interactive-capabilities.json | 68 +-- .../interactiveCapabilityActivator.test.ts | 6 + .../app/navigation/sidebarContributions.ts | 74 ++++ .../app/navigation/sidebarPreferences.test.ts | 72 ++++ .../src/app/navigation/sidebarPreferences.ts | 72 ++++ .../app/navigation/sidebarRegistry.test.ts | 38 ++ .../src/app/navigation/sidebarRegistry.ts | 63 +++ .../app/navigation/useSidebarNavigation.ts | 24 ++ src/web-ui/src/app/scenes/SceneViewport.tsx | 5 - ...ystemCompatibilityScene.discovery.test.tsx | 4 +- ...temCompatibilityScene.presentation.test.ts | 29 +- .../EcosystemCompatibilityScene.scss | 47 +-- .../EcosystemCompatibilityScene.tsx | 391 +++++++++--------- .../ExternalAgentContent.test.tsx | 1 + .../ExternalAgentContent.tsx | 6 +- .../ecosystemCompatibilityStore.ts | 6 +- src/web-ui/src/app/scenes/settings/AGENTS.md | 10 + .../application/AppearanceSettingsPage.scss | 84 ++++ .../application/AppearanceSettingsPage.tsx | 2 + .../SidebarSettingsSection.test.tsx | 126 ++++++ .../application/SidebarSettingsSection.tsx | 82 ++++ .../tools/ExternalImportSettingsPage.tsx | 10 + .../scenes/settings/settingsDestination.ts | 2 +- .../settingsDestinationMigrations.json | 6 + .../scenes/settings/settingsRegistry.test.ts | 11 +- .../app/scenes/settings/settingsRegistry.ts | 22 + .../src/app/scenes/settings/settingsTypes.ts | 2 + .../src/app/scenes/skills/SkillsScene.tsx | 9 +- .../scenes/skills/skillsSceneStore.test.ts | 7 + .../src/app/scenes/skills/skillsSceneStore.ts | 5 + src/web-ui/src/app/stores/sceneStore.test.ts | 8 + src/web-ui/src/app/stores/sceneStore.ts | 7 + .../src/flow_chat/_detail-disclosure.scss | 12 + .../modern/FLOWCHAT_VERIFICATION.md | 10 +- .../modern/FLOWCHAT_VIEWPORT_REGISTER.md | 21 + .../components/modern/FlowChatHeader.test.tsx | 25 ++ .../components/modern/FlowChatHeader.tsx | 27 +- .../modern/ModernFlowChatContainer.tsx | 139 ++++--- .../modern/VirtualItemRenderer.appearance.ts | 1 + .../modern/VirtualItemRenderer.scss | 17 +- .../components/modern/VirtualItemRenderer.tsx | 11 +- ...rtualMessageList.session-boundary.test.tsx | 145 ++++++- .../components/modern/VirtualMessageList.tsx | 109 ++++- .../modern/flowChatSearchPresentation.test.ts | 29 ++ .../modern/flowChatSearchPresentation.ts | 30 ++ .../useFlowChatSearchPresentation.test.tsx | 21 +- .../modern/useFlowChatSearchPresentation.ts | 40 +- .../components/usage/ContextUsagePopover.scss | 25 +- .../components/usage/ContextUsagePopover.tsx | 83 ++-- .../usage/ToolDefinitionsPreview.scss | 10 +- .../flow_chat/components/usage/appearance.ts | 3 +- .../src/flow_chat/timeline/document.test.ts | 10 + src/web-ui/src/flow_chat/timeline/document.ts | 7 +- .../trajectory/Trajectory.appearance.ts | 7 + .../src/flow_chat/trajectory/Trajectory.scss | 128 ++++++ .../trajectory/TrajectoryDetails.test.tsx | 170 ++++++++ .../trajectory/TrajectoryDetails.tsx | 261 ++++++++++++ .../trajectory/TrajectoryExplorer.test.tsx | 224 ++++++++++ .../trajectory/TrajectoryExplorer.tsx | 316 ++++++++++++++ .../trajectory/trajectoryApi.test.ts | 22 + .../src/flow_chat/trajectory/trajectoryApi.ts | 21 + .../trajectory/trajectoryModel.test.ts | 58 +++ .../flow_chat/trajectory/trajectoryModel.ts | 97 +++++ .../trajectory/trajectoryPanel.test.ts | 30 ++ .../flow_chat/trajectory/trajectoryPanel.ts | 46 +++ .../trajectory/trajectoryPresentation.ts | 9 + .../flow_chat/trajectory/trajectoryRange.ts | 27 ++ .../trajectory/useTrajectory.test.tsx | 56 +++ .../src/flow_chat/trajectory/useTrajectory.ts | 77 ++++ .../useTrajectoryNavigation.test.tsx | 122 ++++++ .../trajectory/useTrajectoryNavigation.ts | 136 ++++++ .../api/adapters/peer-device-adapter.ts | 2 + .../api/generated/productControl.ts | 2 +- .../api/generated/remoteSurface.ts | 5 +- .../registry/defaultAppearanceRegistry.ts | 2 + .../peer-device/PeerConnectionManager.test.ts | 3 + .../peer-device/PeerConnectionManager.ts | 4 + src/web-ui/src/locales/en-US/common.json | 11 +- src/web-ui/src/locales/en-US/flow-chat.json | 45 ++ src/web-ui/src/locales/en-US/settings.json | 7 +- .../locales/en-US/settings/appearance.json | 15 + src/web-ui/src/locales/zh-CN/common.json | 11 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 45 ++ src/web-ui/src/locales/zh-CN/settings.json | 7 +- .../locales/zh-CN/settings/appearance.json | 15 + src/web-ui/src/locales/zh-TW/common.json | 11 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 45 ++ src/web-ui/src/locales/zh-TW/settings.json | 7 +- .../locales/zh-TW/settings/appearance.json | 15 + src/web-ui/src/shared/types/panelContent.ts | 1 + 124 files changed, 4444 insertions(+), 1254 deletions(-) create mode 100644 src/crates/services/services-core/src/session/trajectory.rs delete mode 100644 src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.test.tsx delete mode 100644 src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/components/PinnedNavigationItems.tsx create mode 100644 src/web-ui/src/app/navigation/sidebarContributions.ts create mode 100644 src/web-ui/src/app/navigation/sidebarPreferences.test.ts create mode 100644 src/web-ui/src/app/navigation/sidebarPreferences.ts create mode 100644 src/web-ui/src/app/navigation/sidebarRegistry.test.ts create mode 100644 src/web-ui/src/app/navigation/sidebarRegistry.ts create mode 100644 src/web-ui/src/app/navigation/useSidebarNavigation.ts create mode 100644 src/web-ui/src/app/scenes/settings/pages/application/SidebarSettingsSection.test.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/application/SidebarSettingsSection.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/tools/ExternalImportSettingsPage.tsx create mode 100644 src/web-ui/src/flow_chat/_detail-disclosure.scss create mode 100644 src/web-ui/src/flow_chat/trajectory/Trajectory.appearance.ts create mode 100644 src/web-ui/src/flow_chat/trajectory/Trajectory.scss create mode 100644 src/web-ui/src/flow_chat/trajectory/TrajectoryDetails.test.tsx create mode 100644 src/web-ui/src/flow_chat/trajectory/TrajectoryDetails.tsx create mode 100644 src/web-ui/src/flow_chat/trajectory/TrajectoryExplorer.test.tsx create mode 100644 src/web-ui/src/flow_chat/trajectory/TrajectoryExplorer.tsx create mode 100644 src/web-ui/src/flow_chat/trajectory/trajectoryApi.test.ts create mode 100644 src/web-ui/src/flow_chat/trajectory/trajectoryApi.ts create mode 100644 src/web-ui/src/flow_chat/trajectory/trajectoryModel.test.ts create mode 100644 src/web-ui/src/flow_chat/trajectory/trajectoryModel.ts create mode 100644 src/web-ui/src/flow_chat/trajectory/trajectoryPanel.test.ts create mode 100644 src/web-ui/src/flow_chat/trajectory/trajectoryPanel.ts create mode 100644 src/web-ui/src/flow_chat/trajectory/trajectoryPresentation.ts create mode 100644 src/web-ui/src/flow_chat/trajectory/trajectoryRange.ts create mode 100644 src/web-ui/src/flow_chat/trajectory/useTrajectory.test.tsx create mode 100644 src/web-ui/src/flow_chat/trajectory/useTrajectory.ts create mode 100644 src/web-ui/src/flow_chat/trajectory/useTrajectoryNavigation.test.tsx create mode 100644 src/web-ui/src/flow_chat/trajectory/useTrajectoryNavigation.ts diff --git a/design-system/packages/ui/src/components/Icon/Icon.tsx b/design-system/packages/ui/src/components/Icon/Icon.tsx index 0769568425..3426667028 100644 --- a/design-system/packages/ui/src/components/Icon/Icon.tsx +++ b/design-system/packages/ui/src/components/Icon/Icon.tsx @@ -16,6 +16,7 @@ import { BookOpen, BookSearch, CalendarClock, + ChartSpline, Check, ChevronDown, ChevronLeft, @@ -128,6 +129,7 @@ export const iconNames = [ "book-search", "browser", "calendar-clock", + "chart-spline", "check-circle", "check-fill", "check-line", @@ -260,6 +262,7 @@ const lineGlyphs = { "book-search": BookSearch, "browser": Globe, "calendar-clock": CalendarClock, + "chart-spline": ChartSpline, "check-circle": CircleCheck, "check-fill": CircleCheck, "check-line": Check, diff --git a/design-system/packages/ui/tests/icon.test.mjs b/design-system/packages/ui/tests/icon.test.mjs index 124ea63720..3aea68f9b6 100644 --- a/design-system/packages/ui/tests/icon.test.mjs +++ b/design-system/packages/ui/tests/icon.test.mjs @@ -8,14 +8,15 @@ import { Icon, iconNames, canonicalIconNames, iconAliases } from "../dist/index. import { Network } from "lucide-react"; test("Icon exposes the complete named catalog without duplicate names", () => { - assert.equal(iconNames.length, 102); - assert.equal(canonicalIconNames.length, 99); + assert.equal(iconNames.length, 103); + assert.equal(canonicalIconNames.length, 100); assert.deepEqual(Object.keys(iconAliases).sort(), ["circle", "download"]); for (const name of ["thinking", "git", "duplicate", "chevron-left", "selected", "delete", "waitlist-message", "creative", "ultimate", "standard", "minimal", "arrow-down", "unselected"]) { assert.ok(canonicalIconNames.includes(name), name); } assert.equal(new Set(iconNames).size, iconNames.length); assert.ok(iconNames.includes("search")); + assert.match(renderToStaticMarkup(createElement(Icon, { name: "chart-spline" })), /lucide-chart-spline/); assert.ok(canonicalIconNames.includes("list-todo")); assert.match(renderToStaticMarkup(createElement(Icon, { name: "list-todo" })), /lucide-list-todo/); assert.ok(iconNames.includes("book-open")); diff --git a/docs/architecture/product-customization-blueprint.md b/docs/architecture/product-customization-blueprint.md index 785d061f4c..f626e050bc 100644 --- a/docs/architecture/product-customization-blueprint.md +++ b/docs/architecture/product-customization-blueprint.md @@ -227,6 +227,30 @@ GUI 主题由 Web/TS 主题模块定义,TUI 主题由 CLI/TUI 宿主定义,I 界面宿主负责焦点、键盘导航、屏幕阅读器、窄终端、ANSI/truecolor、无鼠标和无 Unicode 等降级。无合法组合时 构建失败;运行时插件贡献无法渲染时使用可退出的降级界面。 +### 7.1 用户侧导航偏好与入口贡献 + +Web UI 的左侧导航定制属于控制端运行时展示偏好,不进入产品定义或远端配置。 +`src/web-ui/src/app/navigation/sidebarRegistry.ts` 定义可信前端模块的贡献接口:稳定的 owner 限定 ID、 +bootstrap 文案键、设计系统图标、当前页面匹配、不可用原因和按需打开动作。 +`sidebarContributions.ts` 注册现有页面并复用页面 owner / Product Action;设置与导航共同消费同一份注册表。 +注册返回注销函数,冲突 ID 拒绝注册,新入口只进入可选列表,由用户决定是否固定。 + +`sidebarPreferences.ts` 只持久化版本和有序入口 ID,不保存回调、组件、工作区路径或权限。 +默认保留首页与任务板,首页固定存在;用户可添加、排序、移除其他入口并恢复默认。 +未知或暂时不可用的入口保留原位置、显式禁用,重新注册或能力恢复后自动复用偏好。 +未知附加字段随写入保留;无法读取的数据和更新版本的记录不自动覆盖;写入失败保留原布局并报错。 +单个 MiniApp 仍由已有的按设备固定机制管理,不能把主机资源 ID 当作跨设备页面 ID。 + +导航贡献不会获得额外权限:页面 owner 继续承担 SSH、Peer 能力判断与数据访问,延迟准备后必须检查 +捕获的 surface scope;不支持的技能页面在非本机桌面显式禁用。Mobile / IM Remote Control 与 Detached +Dispatch 不消费这份 GUI 布局,也不因固定入口获得新协议能力。此接口尚不是第三方插件 API:以后接入插件 +必须由受控适配层把已授权的声明投影为可信贡献,不得从偏好文件执行代码或任意地址。 + +入口声明与用户布局分离、按需加载、可恢复布局参考 +[VS Code View Contributions](https://code.visualstudio.com/api/extension-guides/tree-view) 和 +[Custom Layout](https://code.visualstudio.com/docs/configure/custom-layout);这里只采用适合现有 GUI owner 的部分, +不引入通用组件协议或跨 GUI/TUI 布局 schema。 + ## 8. 产品内置扩展与用户插件 产品内置扩展只表示“随产品交付”,不表示更高运行权限,也不表示同名用户插件必须被拒绝。三类来源的生命周期 diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index a21d37a511..f068f2592f 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **23 个功能**和 **21 个设置页**,共 **44 个**用户可理解的条目、**336 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 +OpenBitFun Playbook 当前包含 **22 个功能**和 **22 个设置页**,共 **44 个**用户可理解的条目、**336 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 -OpenBitFun Playbook currently contains **23 features**, **21 settings pages**, and **336** source-backed sub-capabilities across **44** 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**, **22 settings pages**, and **336** source-backed sub-capabilities across **44** 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,9 +27,9 @@ OpenBitFun Playbook currently contains **23 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 或可解析的源码标记;这些证据不会进入公开目录。当前 **706** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **707** 个 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 **706** 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 **707** 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 diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 4c33f27459..ce4e55a988 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": "9ec82305067852766c79ed3d802c3a16f1c4dd992f7d3195f91a2989da32f544", + "digest": "cda0c1805851b3488a613c44f6848376dd9093cf08325257d88cd9ab16d85fae", "ownerDigest": "f27c231da2e536e4f2123910f34f862b55d21feb0528bc915d4d13edbd528af9", "searchAcceptance": [ { @@ -172,8 +172,8 @@ } ], "counts": { - "features": 23, - "settings": 21, + "features": 22, + "settings": 22, "userFacing": 44, "documentedItems": 336, "controlCoverage": { @@ -8046,8 +8046,8 @@ "kind": "static" }, "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8092,8 +8092,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8138,8 +8138,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8184,8 +8184,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8230,8 +8230,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8276,8 +8276,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8322,8 +8322,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8368,8 +8368,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8414,8 +8414,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8460,8 +8460,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8506,8 +8506,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8552,8 +8552,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -24420,7 +24420,7 @@ }, { "id": "feature.ecosystem-compatibility", - "kind": "feature", + "kind": "setting", "categoryId": "automation", "titleZh": "外部导入", "titleEn": "External Import", @@ -24591,15 +24591,15 @@ } ], "stepsZh": [ - "打开“外部导入”并选择应用", - "在左侧导航搜索框上方切换自动发现,悬停查看说明;关闭后立即隐藏对应来源的跟随会话,已分叉副本继续保留", + "打开“设置 → 工具与集成 → 外部导入”并选择应用", + "在外部应用标题旁切换自动发现,悬停查看说明;关闭后立即隐藏对应来源的跟随会话,已分叉副本继续保留", "查看分类状态,打开分类弹窗浏览、搜索并选择内容", "审阅可导入项目并确认创建副本;Command、Tool、子智能体按已有兼容功能使用", "前往对应管理页确认副本启用或连接;当前页不将“已导入”视为可用" ], "stepsEn": [ - "Open External Import and choose an app", - "Toggle automatic discovery above the sidebar search and hover for details; disabling immediately hides source-following sessions while local forks remain visible", + "Open Settings → Tools & integrations → External import and choose an app", + "Toggle automatic discovery beside the External apps heading and hover for details; disabling immediately hides source-following sessions while local forks remain visible", "Inspect category status, then open a category dialog to browse, search, and select content", "Review importable items and confirm copies; use commands, tools and subagents through existing compatibility features", "Confirm copy activation or connection in the relevant settings; Imported does not mean available" @@ -24613,8 +24613,8 @@ "I want to migrate my Claude Code configuration" ], "destination": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "operations": [], "options": [], diff --git a/docs/interactive-capabilities/capabilities/feature.ecosystem-compatibility.md b/docs/interactive-capabilities/capabilities/feature.ecosystem-compatibility.md index 7bb44638a7..afaf07dae9 100644 --- a/docs/interactive-capabilities/capabilities/feature.ecosystem-compatibility.md +++ b/docs/interactive-capabilities/capabilities/feature.ecosystem-compatibility.md @@ -1,7 +1,7 @@ --- id: feature.ecosystem-compatibility -kind: feature +kind: setting category: automation title_zh: "外部导入" title_en: "External Import" @@ -9,7 +9,7 @@ title_en: "External Import" # 外部导入 / External Import -> 功能 / Feature +> 设置 / Setting 查看外部 AI 生态的内容与状态,导入受支持的 Skill、MCP、Hook,以及 Codex、Zcode 会话,并在 OpenBitFun 中继续对话。 @@ -42,10 +42,10 @@ Inspect external AI content and status, import supported Skills, MCP, Hooks, and ## 怎么用 / How to use it -1. 打开“外部导入”并选择应用 - Open External Import and choose an app -2. 在左侧导航搜索框上方切换自动发现,悬停查看说明;关闭后立即隐藏对应来源的跟随会话,已分叉副本继续保留 - Toggle automatic discovery above the sidebar search and hover for details; disabling immediately hides source-following sessions while local forks remain visible +1. 打开“设置 → 工具与集成 → 外部导入”并选择应用 + Open Settings → Tools & integrations → External import and choose an app +2. 在外部应用标题旁切换自动发现,悬停查看说明;关闭后立即隐藏对应来源的跟随会话,已分叉副本继续保留 + Toggle automatic discovery beside the External apps heading and hover for details; disabling immediately hides source-following sessions while local forks remain visible 3. 查看分类状态,打开分类弹窗浏览、搜索并选择内容 Inspect category status, then open a category dialog to browse, search, and select content 4. 审阅可导入项目并确认创建副本;Command、Tool、子智能体按已有兼容功能使用 @@ -53,7 +53,7 @@ Inspect external AI content and status, import supported Skills, MCP, Hooks, and 5. 前往对应管理页确认副本启用或连接;当前页不将“已导入”视为可用 Confirm copy activation or connection in the relevant settings; Imported does not mean available -入口 / Entry: OpenBitFun 功能入口 +入口 / Entry: OpenBitFun 设置 ## 智能体可替你做什么 / What an agent can do for you diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index 2247e29765..902957a79c 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "9ec82305067852766c79ed3d802c3a16f1c4dd992f7d3195f91a2989da32f544", + "catalogDigest": "cda0c1805851b3488a613c44f6848376dd9093cf08325257d88cd9ab16d85fae", "count": 213, "reasonCounts": { "externalAuth": 4, @@ -1626,8 +1626,8 @@ "reasonZh": "“发现 OpenCode、Claude Code、Codex 等外部 AI 来源与可用内容”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", "reasonEn": "“Discover OpenCode, Claude Code, Codex, and other external AI sources and content” 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.", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "evidence": [ "command:get_external_ecosystem_awareness_command", @@ -1646,8 +1646,8 @@ "reasonZh": "“按来源启用或停用集成,并选择管理、预览或只读策略”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", "reasonEn": "“Enable or disable each source and choose managed, preview, or read-only policy” 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.", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "evidence": [ "command:set_external_source_enabled_command", @@ -1664,8 +1664,8 @@ "reasonZh": "“发现、移除和重新添加 Codex 来源会话,并在 OpenBitFun 分支中续聊”由用户在生态兼容页开启发现或选择已移除的来源。打开来源时刷新历史,首次发送创建独立分支。归档、移除和分支删除各自独立,原应用历史保持不变。智能体打开入口,由用户选择具体会话。", "reasonEn": "“Discover, remove and restore Codex source sessions, then continue in OpenBitFun branches” requires the user to enable discovery or select a removed source in ecosystem compatibility. Opening a source refreshes its history and first send creates an independent branch. Archives, source removal and branch deletion stay independent; original history is preserved. The Agent opens the entry for the user to choose a session.", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "evidence": [ "command:list_removed_external_sessions", @@ -1683,8 +1683,8 @@ "reasonZh": "“发现、移除和重新添加 Zcode 来源会话,并在 OpenBitFun 分支中续聊”由用户在生态兼容页开启发现或选择已移除的来源。打开来源时刷新历史,首次发送创建独立分支。归档、移除和分支删除各自独立,原应用历史保持不变。智能体打开入口,由用户选择具体会话。", "reasonEn": "“Discover, remove and restore Zcode source sessions, then continue in OpenBitFun branches” requires the user to enable discovery or select a removed source in ecosystem compatibility. Opening a source refreshes its history and first send creates an independent branch. Archives, source removal and branch deletion stay independent; original history is preserved. The Agent opens the entry for the user to choose a session.", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "evidence": [ "command:list_removed_external_sessions", @@ -1702,8 +1702,8 @@ "reasonZh": "“展开外部 Prompt/Command 内容并处理与原生命令的冲突”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", "reasonEn": "“Expand external prompt/command content and resolve conflicts with native commands” 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.", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "evidence": [ "command:expand_external_prompt_command_command", @@ -1720,8 +1720,8 @@ "reasonZh": "“启用外部智能体,处理同名冲突并绑定模型”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", "reasonEn": "“Enable external agents, resolve naming conflicts, and bind models” 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.", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "evidence": [ "command:choose_external_subagent_conflict_command", @@ -1739,8 +1739,8 @@ "reasonZh": "“预览 MCP 导入计划,逐个处理服务器冲突并应用”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", "reasonEn": "“Preview MCP import plans, resolve server conflicts individually, and apply them” 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.", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "evidence": [ "command:plan_external_mcp_import_command", @@ -1759,8 +1759,8 @@ "reasonZh": "“选择外部工具目标、解决冲突并控制是否启用”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", "reasonEn": "“Choose external tool targets, resolve conflicts, and control activation” 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.", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "evidence": [ "command:set_external_tool_target_decision_command", @@ -1777,8 +1777,8 @@ "reasonZh": "“查看外部 Hooks 目录、规划导入、修改选择并应用”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", "reasonEn": "“Inspect external hook catalogs, plan imports, edit selections, and apply” 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.", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "evidence": [ "command:get_external_hook_catalog", @@ -1796,8 +1796,8 @@ "reasonZh": "“在迁移前查看冲突、安全影响和来源控制快照”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", "reasonEn": "“Review conflicts, security impact, and source-control snapshots before migration” 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.", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "evidence": [ "command:get_external_source_control_snapshot", @@ -1814,8 +1814,8 @@ "reasonZh": "“在文件管理器或远程环境中定位外部配置来源”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", "reasonEn": "“Reveal external configuration sources in the file manager or remote environment” 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.", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "evidence": [ "command:reveal_external_source_location", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 232541ab0d..94a611580e 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": "9ec82305067852766c79ed3d802c3a16f1c4dd992f7d3195f91a2989da32f544", - "commandCount": 706, + "catalogDigest": "cda0c1805851b3488a613c44f6848376dd9093cf08325257d88cd9ab16d85fae", + "commandCount": 707, "coverage": { - "commandCount": 706, - "documentedCommandCount": 646, + "commandCount": 707, + "documentedCommandCount": 647, "implementationCommandCount": 60, "implementationDigest": "5047b674e890f899e65baf9235e9df4bd912fa28f44eb10a656e079a1b89974d" }, @@ -5815,6 +5815,22 @@ "signature": "fn load_session_event_backfill( runtime: State<'_, DesktopRuntimeContext>, request: LoadSessionEventBackfillRequest, ) -> Result", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "load_session_trajectory", + "moduleId": "agentic", + "capabilityId": "feature.ai-assistant", + "capabilityIds": [ + "feature.ai-assistant" + ], + "documentedItemIds": [ + "feature.ai-assistant:history-search" + ], + "visibility": "documented", + "rustPath": "api::agentic_api::load_session_trajectory", + "sourceFile": "src/apps/desktop/src/api/agentic_api.rs", + "signature": "fn load_session_trajectory( runtime: State<'_, DesktopRuntimeContext>, request: LoadSessionTrajectoryRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteRouted" + }, { "id": "load_session_turn_window", "moduleId": "agentic", diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index ad404ea726..025b0924b7 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -146,6 +146,7 @@ pub(crate) fn dispatch<'a>( Box::pin(session::list_persisted_sessions_count(state, args)) } "search_session_content" => Box::pin(session::search_session_content(state, args)), + "load_session_trajectory" => Box::pin(session::load_session_trajectory(state, args)), "load_session_turn_window" => Box::pin(session::load_session_turn_window(state, args)), "load_session_turns" => Box::pin(session::load_session_turns(state, args)), "load_session_event_backfill" => Box::pin(std::future::ready(session::load_session_event_backfill(state, args))), @@ -332,6 +333,7 @@ pub(crate) const HANDLED_COMMANDS: &[&str] = &[ "list_project_permission_grants", "load_session_event_backfill", "load_session_turn_window", + "load_session_trajectory", "load_session_turns", "open_workspace", "product_control_invoke", diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index ab758e4975..ba3853aabc 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -453,6 +453,21 @@ pub(crate) async fn load_session_turns( pub(crate) async fn load_session_turn_window( state: &PeerHostState, args: &Value, +) -> Result { + read_session_turn_window(state, args, false).await +} + +pub(crate) async fn load_session_trajectory( + state: &PeerHostState, + args: &Value, +) -> Result { + read_session_turn_window(state, args, true).await +} + +async fn read_session_turn_window( + state: &PeerHostState, + args: &Value, + trajectory: bool, ) -> Result { let request = request_value(args); let session_id = validated_session_id(request)?; @@ -465,19 +480,62 @@ pub(crate) async fn load_session_turn_window( workspace_path: workspace_path.clone(), session_id, include_internal: optional_bool(request, "includeInternal").unwrap_or(false), - target_storage_turn_index: request - .get("targetStorageTurnIndex") - .and_then(Value::as_u64) - .ok_or_else(|| "targetStorageTurnIndex is required".to_string())? - as usize, + target_storage_turn_index: if trajectory + && optional_bool(request, "catalogOnly").unwrap_or(false) + { + usize::MAX + } else { + request + .get("targetStorageTurnIndex") + .and_then(Value::as_u64) + .ok_or_else(|| "targetStorageTurnIndex is required".to_string())? + as usize + }, expected_turn_id: optional_string(request, "expectedTurnId"), expected_catalog_revision: optional_string(request, "expectedCatalogRevision"), - before: request.get("before").and_then(Value::as_u64).unwrap_or(4) as usize, - after: request.get("after").and_then(Value::as_u64).unwrap_or(12) as usize, + before: if trajectory { + 0 + } else { + request.get("before").and_then(Value::as_u64).unwrap_or(4) as usize + }, + after: if trajectory { + request + .get("after") + .and_then(Value::as_u64) + .unwrap_or(8) + .clamp(1, 8) as usize + } else { + request.get("after").and_then(Value::as_u64).unwrap_or(12) as usize + }, }, ) .await .map_err(|e| format!("Failed to load session Turn window: {e}"))?; + if trajectory && optional_bool(request, "catalogOnly").unwrap_or(false) { + if let openbitfun_services_core::session::SessionTurnWindowResponse::NotFound { catalog } + | openbitfun_services_core::session::SessionTurnWindowResponse::Stale { catalog } = + response + { + return serde_json::to_value( + openbitfun_services_core::session::trajectory::SessionTrajectoryPage::Catalog { + catalog, + }, + ) + .map_err(|e| format!("serialize trajectory catalog: {e}")); + } + return Err("Unexpected trajectory catalog response".into()); + } + if trajectory { + let page = openbitfun_services_core::session::trajectory::project_session_trajectory( + response, + request.get("query").and_then(Value::as_str).unwrap_or(""), + request + .get("nodeOffset") + .and_then(Value::as_u64) + .unwrap_or(0) as usize, + ); + return serde_json::to_value(page).map_err(|e| format!("serialize trajectory: {e}")); + } serde_json::to_value(response).map_err(|e| format!("serialize Turn window: {e}")) } diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index cf817297af..58cca30e76 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -20,7 +20,7 @@ serde_json = { workspace = true } [dependencies] openbitfun-miniapp-ipc = { path = "../../crates/adapters/miniapp-ipc" } -openbitfun-services-core = { path = "../../crates/services/services-core", features = ["pet-packages"] } +openbitfun-services-core = { path = "../../crates/services/services-core", features = ["pet-packages", "local-storage"] } # Internal crates openbitfun-core = { path = "../../crates/assembly/core", features = ["product-full"] } openbitfun-relay-service = { path = "../../crates/services/relay-service" } diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 02f67d7355..ec55df8892 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -3887,6 +3887,72 @@ pub async fn load_session_event_backfill( )) } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoadSessionTrajectoryRequest { + #[serde(default)] + pub catalog_only: bool, + #[serde(flatten)] + pub window: LoadSessionTurnWindowRequest, + #[serde(default)] + pub query: String, + #[serde(default)] + pub node_offset: usize, +} + +#[tauri::command] +pub async fn load_session_trajectory( + runtime: State<'_, DesktopRuntimeContext>, + request: LoadSessionTrajectoryRequest, +) -> Result { + let window = request.window; + let response = runtime + .session_application() + .load_session_turn_window( + desktop_session_scope( + window.workspace_id, + window.workspace_path.clone(), + window.remote_connection_id, + window.remote_ssh_host, + ), + SessionTurnWindowRequest { + workspace_path: PathBuf::from(window.workspace_path), + session_id: window.session_id, + include_internal: window.include_internal, + target_storage_turn_index: if request.catalog_only { + usize::MAX + } else { + window.target_storage_turn_index + }, + expected_turn_id: window.expected_turn_id, + expected_catalog_revision: window.expected_catalog_revision, + before: 0, + after: window.after.unwrap_or(8).clamp(1, 8), + }, + ) + .await + .map_err(|error| error.to_string())?; + if request.catalog_only { + if let SessionTurnWindowResponse::NotFound { catalog } + | SessionTurnWindowResponse::Stale { catalog } = response + { + return Ok( + openbitfun_services_core::session::trajectory::SessionTrajectoryPage::Catalog { + catalog, + }, + ); + } + return Err("Unexpected trajectory catalog response".into()); + } + Ok( + openbitfun_services_core::session::trajectory::project_session_trajectory( + response, + &request.query, + request.node_offset, + ), + ) +} + #[tauri::command] pub async fn load_session_turn_window( runtime: State<'_, DesktopRuntimeContext>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 129455f32f..6ed7aa8a7b 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1419,6 +1419,7 @@ pub async fn run() { api::agentic_api::restore_session_view, api::agentic_api::load_session_event_backfill, api::agentic_api::load_session_turn_window, + api::agentic_api::load_session_trajectory, api::agentic_api::restore_session_with_turns, api::agentic_api::reset_memory, api::agentic_api::get_memory_paths, 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 a632771ff9..281cd57b28 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": "9ec82305067852766c79ed3d802c3a16f1c4dd992f7d3195f91a2989da32f544", + "digest": "cda0c1805851b3488a613c44f6848376dd9093cf08325257d88cd9ab16d85fae", "ownerDigest": "f27c231da2e536e4f2123910f34f862b55d21feb0528bc915d4d13edbd528af9", "searchAcceptance": [ { @@ -172,8 +172,8 @@ } ], "counts": { - "features": 23, - "settings": 21, + "features": 22, + "settings": 22, "userFacing": 44, "documentedItems": 336, "controlCoverage": { @@ -8046,8 +8046,8 @@ "kind": "static" }, "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8092,8 +8092,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8138,8 +8138,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8184,8 +8184,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8230,8 +8230,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8276,8 +8276,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8322,8 +8322,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8368,8 +8368,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8414,8 +8414,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8460,8 +8460,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8506,8 +8506,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -8552,8 +8552,8 @@ }, "openReason": "unstructuredInteraction", "presentationTarget": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" } }, { @@ -24461,7 +24461,7 @@ }, { "id": "feature.ecosystem-compatibility", - "kind": "feature", + "kind": "setting", "categoryId": "automation", "titleZh": "外部导入", "titleEn": "External Import", @@ -24632,15 +24632,15 @@ } ], "stepsZh": [ - "打开“外部导入”并选择应用", - "在左侧导航搜索框上方切换自动发现,悬停查看说明;关闭后立即隐藏对应来源的跟随会话,已分叉副本继续保留", + "打开“设置 → 工具与集成 → 外部导入”并选择应用", + "在外部应用标题旁切换自动发现,悬停查看说明;关闭后立即隐藏对应来源的跟随会话,已分叉副本继续保留", "查看分类状态,打开分类弹窗浏览、搜索并选择内容", "审阅可导入项目并确认创建副本;Command、Tool、子智能体按已有兼容功能使用", "前往对应管理页确认副本启用或连接;当前页不将“已导入”视为可用" ], "stepsEn": [ - "Open External Import and choose an app", - "Toggle automatic discovery above the sidebar search and hover for details; disabling immediately hides source-following sessions while local forks remain visible", + "Open Settings → Tools & integrations → External import and choose an app", + "Toggle automatic discovery beside the External apps heading and hover for details; disabling immediately hides source-following sessions while local forks remain visible", "Inspect category status, then open a category dialog to browse, search, and select content", "Review importable items and confirm copies; use commands, tools and subagents through existing compatibility features", "Confirm copy activation or connection in the relevant settings; Imported does not mean available" @@ -24654,8 +24654,8 @@ "I want to migrate my Claude Code configuration" ], "destination": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "operations": [], "options": [], 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 7538a8b440..c6348b5cba 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:99d567cb0f1aa0ad", + "digest": "fnv1a64:96fc351cf4dd6568", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -44,6 +44,7 @@ "workspace_id_references_v1", "workspace_html_preview_v1", "whole_session_copy_v1", + "session_trajectory_v1", "user_question_response", "user_question_interaction_v1" ], @@ -83,6 +84,7 @@ "workspace_id_references_v1", "workspace_html_preview_v1", "whole_session_copy_v1", + "session_trajectory_v1", "user_question_response", "user_question_interaction_v1" ], @@ -101,6 +103,7 @@ "product_control_v1", "cancel_tool", "tool_catalog", + "session_trajectory_v1", "chat_mcp_catalog_v1", "workspace_id_references_v1", "workspace_html_preview_v1", @@ -4595,6 +4598,17 @@ "kind": "handled" } }, + { + "id": "load_session_trajectory", + "surface": "tauri_command", + "remoteWorkspace": "RemoteRouted", + "peer": { + "kind": "proxied" + }, + "cliPeer": { + "kind": "handled" + } + }, { "id": "load_session_turn_window", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs index 1c5c432e9f..b2b33d1b4a 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs @@ -91,6 +91,8 @@ pub enum PeerHostCapability { WorkspaceHtmlPreviewV1, /// `copy_session` copies a whole session without a caller-selected turn id. WholeSessionCopyV1, + /// Bounded session trajectory summaries and host-side body search. + SessionTrajectoryV1, /// `submit_user_answers` is implemented for Runtime-owned questions. UserQuestionResponse, /// First human interaction disables the unattended question timeout. @@ -135,6 +137,7 @@ impl PeerHostCapability { Self::WorkspaceIdReferencesV1, Self::WorkspaceHtmlPreviewV1, Self::WholeSessionCopyV1, + Self::SessionTrajectoryV1, Self::UserQuestionResponse, Self::UserQuestionInteractionV1, ]; @@ -176,6 +179,7 @@ impl PeerHostCapability { Self::ChatMcpCatalogV1 => "chat_mcp_catalog_v1", Self::WorkspaceIdReferencesV1 => "workspace_id_references_v1", Self::WorkspaceHtmlPreviewV1 => "workspace_html_preview_v1", + Self::SessionTrajectoryV1 => "session_trajectory_v1", Self::WholeSessionCopyV1 => "whole_session_copy_v1", Self::UserQuestionResponse => "user_question_response", Self::UserQuestionInteractionV1 => "user_question_interaction_v1", @@ -207,6 +211,7 @@ const CLI_CAPABILITIES: &[PeerHostCapability] = &[ PeerHostCapability::ProductControlV1, PeerHostCapability::CancelTool, PeerHostCapability::ToolCatalog, + PeerHostCapability::SessionTrajectoryV1, PeerHostCapability::ChatMcpCatalogV1, PeerHostCapability::WorkspaceIdReferencesV1, PeerHostCapability::WorkspaceHtmlPreviewV1, 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 aed4c41da7..596a57580c 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -469,6 +469,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("load_mcp_json_config", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("load_persisted_session_metadata", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("load_session_event_backfill", Agnostic, Proxied, HANDLED), + op("load_session_trajectory", Routed, Proxied, HANDLED), op("load_session_turn_window", Routed, Proxied, HANDLED), op("load_session_turns", Unaudited, Proxied, HANDLED), op("local_file_download", Agnostic, ControllerLocal, REFUSED), diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index 9c3f91c5ce..e920b035b6 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -99,6 +99,7 @@ cargo test -p openbitfun-services-core --no-default-features --features diff --l cargo test -p openbitfun-services-core --no-default-features --features workspace-text-runtime --lib workspace_text::tests:: cargo test -p openbitfun-services-core --no-default-features --features workspace-runtime --lib workspace::tests:: cargo test -p openbitfun-services-core --no-default-features --features local-storage --test session_contracts session_metadata_contracts:: +cargo test -p openbitfun-services-core --no-default-features --features local-storage --lib session::trajectory cargo test -p openbitfun-services-core --no-default-features --features local-storage --lib session::metadata cargo test -p openbitfun-services-core --no-default-features --features local-storage --lib session::page::tests cargo test -p openbitfun-services-core --no-default-features --features local-storage --lib session::offline_import diff --git a/src/crates/services/services-core/src/session/mod.rs b/src/crates/services/services-core/src/session/mod.rs index 059cd50b58..4a95bde9b5 100644 --- a/src/crates/services/services-core/src/session/mod.rs +++ b/src/crates/services/services-core/src/session/mod.rs @@ -8,6 +8,7 @@ pub mod naming; mod offline_import; mod ordinal; pub mod page; +pub mod trajectory; pub mod types; mod write_lock; diff --git a/src/crates/services/services-core/src/session/trajectory.rs b/src/crates/services/services-core/src/session/trajectory.rs new file mode 100644 index 0000000000..6fe309873f --- /dev/null +++ b/src/crates/services/services-core/src/session/trajectory.rs @@ -0,0 +1,348 @@ +//! Bounded read-only projections of persisted conversation windows. Bodies stay +//! in the existing history reader; this response never transports tool results. +use super::{DialogTurnData, SessionTurnCatalog, SessionTurnWindowResponse}; +use serde::{Deserialize, Serialize}; + +pub const TRAJECTORY_PAGE_SIZE: usize = 256; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TrajectoryNode { + pub id: String, + pub turn_id: String, + pub turn_ordinal: usize, + pub kind: String, + pub round_id: Option, + pub focus_item_id: Option, + pub label: String, + pub preview: String, + pub status: String, + pub timestamp: u64, + pub duration_ms: Option, + pub matched: bool, + pub match_field: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde( + tag = "status", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum SessionTrajectoryPage { + Ready { + catalog_revision: String, + start_ordinal: usize, + end_ordinal_exclusive: usize, + total_turn_count: usize, + nodes: Vec, + next_node_offset: Option, + }, + Catalog { + catalog: SessionTurnCatalog, + }, + Stale { + catalog: SessionTurnCatalog, + }, + NotFound { + catalog: SessionTurnCatalog, + }, +} + +fn preview(value: &str) -> String { + value.chars().take(160).collect() +} +fn contains(value: &str, query: &str) -> bool { + !query.is_empty() && value.to_lowercase().contains(query) +} +fn json_contains(value: &serde_json::Value, query: &str) -> bool { + if query.is_empty() { + return false; + } + match value { + serde_json::Value::String(value) => contains(value, query), + serde_json::Value::Array(values) => values.iter().any(|v| json_contains(v, query)), + serde_json::Value::Object(values) => values + .iter() + .any(|(k, v)| contains(k, query) || json_contains(v, query)), + value => contains(&value.to_string(), query), + } +} + +fn project_turn( + turn: &DialogTurnData, + ordinal: usize, + query: &str, + accept: &mut impl FnMut() -> bool, + emit: &mut impl FnMut(TrajectoryNode), +) { + if !turn.kind.is_transcript_visible() { + return; + } + if accept() { + emit(TrajectoryNode { + id: turn.user_message.id.clone(), + turn_id: turn.turn_id.clone(), + turn_ordinal: ordinal, + kind: "user".into(), + round_id: None, + focus_item_id: None, + label: String::new(), + preview: preview(&turn.user_message.content), + status: "completed".into(), + timestamp: turn.user_message.timestamp, + duration_ms: None, + matched: contains(&turn.user_message.content, query), + match_field: Some("content".into()), + }); + } + for round in &turn.model_rounds { + if accept() { + let label = round + .effective_model_name + .as_deref() + .or(round.model_config_id.as_deref()) + .unwrap_or(""); + let text = round + .text_items + .iter() + .find(|item| contains(&item.content, query)); + let thinking = round + .thinking_items + .iter() + .find(|item| contains(&item.content, query)); + let focus = text + .map(|item| item.id.clone()) + .or_else(|| thinking.map(|item| item.id.clone())) + .or_else(|| round.text_items.first().map(|item| item.id.clone())) + .or_else(|| round.thinking_items.first().map(|item| item.id.clone())) + .or_else(|| round.tool_items.first().map(|item| item.id.clone())); + emit(TrajectoryNode { + id: round.id.clone(), + turn_id: turn.turn_id.clone(), + turn_ordinal: ordinal, + kind: "model".into(), + round_id: Some(round.id.clone()), + focus_item_id: focus, + label: preview(label), + preview: preview( + text.map(|item| item.content.as_str()) + .or_else(|| round.text_items.first().map(|item| item.content.as_str())) + .unwrap_or(""), + ), + status: round.status.clone(), + timestamp: round.start_time, + duration_ms: round.duration_ms, + matched: contains(label, query) || text.is_some() || thinking.is_some(), + match_field: if text.is_some() || thinking.is_some() { + Some("content".into()) + } else { + None + }, + }); + } + for tool in &round.tool_items { + if !accept() { + continue; + } + let field = if contains(&tool.tool_name, query) { + Some("name") + } else if json_contains(&tool.tool_call.input, query) { + Some("parameters") + } else if tool.tool_result.as_ref().is_some_and(|result| { + json_contains(&result.result, query) + || result + .result_for_assistant + .as_ref() + .is_some_and(|s| contains(s, query)) + || result.error.as_ref().is_some_and(|s| contains(s, query)) + }) { + Some("result") + } else { + None + }; + emit(TrajectoryNode { + id: tool.id.clone(), + turn_id: turn.turn_id.clone(), + turn_ordinal: ordinal, + kind: "tool".into(), + round_id: Some(round.id.clone()), + focus_item_id: Some(tool.id.clone()), + label: preview(&tool.tool_name), + preview: String::new(), + status: tool.status.clone().unwrap_or_else(|| { + if tool.tool_result.as_ref().is_some_and(|r| r.success) { + "completed" + } else { + "unknown" + } + .into() + }), + timestamp: tool.start_time, + duration_ms: tool.duration_ms, + matched: field.is_some(), + match_field: field.map(str::to_owned), + }); + } + } +} + +pub fn project_session_trajectory( + window: SessionTurnWindowResponse, + query: &str, + offset: usize, +) -> SessionTrajectoryPage { + match window { + SessionTurnWindowResponse::Ready { + catalog_revision, + start_ordinal, + end_ordinal_exclusive, + total_turn_count, + turns, + .. + } => { + let query = query.trim().to_lowercase(); + let mut nodes = Vec::with_capacity(TRAJECTORY_PAGE_SIZE); + let mut count = 0usize; + for (index, turn) in turns.iter().enumerate() { + project_turn( + turn, + start_ordinal + index, + &query, + &mut || { + let include = count >= offset && count - offset < TRAJECTORY_PAGE_SIZE; + count += 1; + include + }, + &mut |node| nodes.push(node), + ); + } + SessionTrajectoryPage::Ready { + catalog_revision, + start_ordinal, + end_ordinal_exclusive, + total_turn_count, + next_node_offset: (count > offset.saturating_add(nodes.len())) + .then_some(offset.saturating_add(nodes.len())), + nodes, + } + } + SessionTurnWindowResponse::Stale { catalog } => SessionTrajectoryPage::Stale { catalog }, + SessionTurnWindowResponse::NotFound { catalog } => { + SessionTrajectoryPage::NotFound { catalog } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn turn() -> DialogTurnData { + serde_json::from_value(json!({ + "turnId":"turn", "turnIndex":7, "sessionId":"session", "timestamp":100, + "userMessage":{"id":"user","content":"Find the needle","timestamp":100}, + "modelRounds":[{"id":"round","turnId":"turn","roundIndex":0,"timestamp":100, + "startTime":100,"status":"completed", + "textItems":[{"id":"text","content":"A needle","isStreaming":false,"timestamp":110}], + "toolItems":[{"id":"tool","toolName":"Read","startTime":120, + "toolCall":{"id":"call","input":{"path":"/NEEDLE"}}, + "toolResult":{"result":{"output":"FOUND"},"success":true}}]}], + "startTime":100,"status":"completed" + })).unwrap() + } + fn page(turns: Vec, query: &str, offset: usize) -> SessionTrajectoryPage { + project_session_trajectory( + SessionTurnWindowResponse::Ready { + catalog_revision: "revision".into(), + total_turn_count: turns.len(), + start_ordinal: 4, + end_ordinal_exclusive: 4 + turns.len(), + target_turn_id: "turn".into(), + turns, + }, + query, + offset, + ) + } + #[test] + fn legacy_records_keep_unknown_timing_and_search_concrete_sources() { + let original = turn(); + let serialized = serde_json::to_value(&original).unwrap(); + let restored: DialogTurnData = serde_json::from_value(serialized).unwrap(); + let SessionTrajectoryPage::Ready { nodes, .. } = page(vec![restored], "needle", 0) else { + panic!() + }; + assert_eq!(nodes.len(), 3); + assert!(nodes + .iter() + .all(|node| node.matched && node.turn_ordinal == 4)); + assert_eq!(nodes[1].focus_item_id.as_deref(), Some("text")); + assert_eq!(nodes[2].match_field.as_deref(), Some("parameters")); + assert!(nodes[2].duration_ms.is_none()); + let wire = serde_json::to_string(&nodes).unwrap(); + assert!(!wire.contains("/NEEDLE")); + assert!(!wire.contains("FOUND")); + let SessionTrajectoryPage::Ready { nodes, .. } = page(vec![original], "found", 0) else { + panic!() + }; + assert_eq!(nodes[2].match_field.as_deref(), Some("result")); + } + #[test] + fn large_single_turn_is_bounded_and_cursor_continues_without_dropping_nodes() { + let mut turn = turn(); + let template = turn.model_rounds[0].tool_items[0].clone(); + turn.model_rounds[0].tool_items = (0..600) + .map(|i| { + let mut tool = template.clone(); + tool.id = format!("tool-{i}"); + tool + }) + .collect(); + let mut offset = 0; + let mut ids = Vec::new(); + loop { + let SessionTrajectoryPage::Ready { + nodes, + next_node_offset, + .. + } = page(vec![turn.clone()], "", offset) + else { + panic!() + }; + assert!(nodes.len() <= TRAJECTORY_PAGE_SIZE); + ids.extend(nodes.into_iter().map(|node| node.id)); + match next_node_offset { + Some(next) => { + assert!(next > offset); + offset = next; + } + None => break, + } + } + assert_eq!(ids.len(), 602); + assert_eq!( + ids.into_iter() + .collect::>() + .len(), + 602 + ); + } + #[test] + fn stale_catalog_is_preserved_instead_of_returning_empty_success() { + let catalog = SessionTurnCatalog { + schema_version: 1, + session_id: "session".into(), + revision: "new".into(), + total_turn_count: 0, + complete: true, + entries: vec![], + }; + let response = + project_session_trajectory(SessionTurnWindowResponse::Stale { catalog }, "", 0); + assert!( + matches!(response, SessionTrajectoryPage::Stale { catalog } if catalog.revision == "new") + ); + } +} diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index af0b246aa4..a895c18f11 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -535,6 +535,7 @@ }, "evidence": [ "command:load_session_turn_window", + "command:load_session_trajectory", "command:search_session_content", "command:load_session_event_backfill", "command:list_persisted_sessions_page", @@ -3958,7 +3959,7 @@ }, { "id": "feature.ecosystem-compatibility", - "kind": "feature", + "kind": "setting", "categoryId": "automation", "titleZh": "外部导入", "titleEn": "External Import", @@ -4191,15 +4192,15 @@ } ], "stepsZh": [ - "打开“外部导入”并选择应用", - "在左侧导航搜索框上方切换自动发现,悬停查看说明;关闭后立即隐藏对应来源的跟随会话,已分叉副本继续保留", + "打开“设置 → 工具与集成 → 外部导入”并选择应用", + "在外部应用标题旁切换自动发现,悬停查看说明;关闭后立即隐藏对应来源的跟随会话,已分叉副本继续保留", "查看分类状态,打开分类弹窗浏览、搜索并选择内容", "审阅可导入项目并确认创建副本;Command、Tool、子智能体按已有兼容功能使用", "前往对应管理页确认副本启用或连接;当前页不将“已导入”视为可用" ], "stepsEn": [ - "Open External Import and choose an app", - "Toggle automatic discovery above the sidebar search and hover for details; disabling immediately hides source-following sessions while local forks remain visible", + "Open Settings → Tools & integrations → External import and choose an app", + "Toggle automatic discovery beside the External apps heading and hover for details; disabling immediately hides source-following sessions while local forks remain visible", "Inspect category status, then open a category dialog to browse, search, and select content", "Review importable items and confirm copies; use commands, tools and subagents through existing compatibility features", "Confirm copy activation or connection in the relevant settings; Imported does not mean available" @@ -4213,8 +4214,8 @@ "I want to migrate my Claude Code configuration" ], "destination": { - "kind": "action", - "actionId": "surface.ecosystemCompatibility.open" + "kind": "settings", + "pageId": "tools.external-import" }, "operations": [] }, diff --git a/src/web-ui/README.md b/src/web-ui/README.md index 367edb0871..1b5a1dc0f5 100644 --- a/src/web-ui/README.md +++ b/src/web-ui/README.md @@ -77,11 +77,25 @@ still wait for the current turn; Detached Dispatch sessions keep appending messages to the target task. In Peer Device Mode, the preference belongs to the active host, as do the other conversation settings. +## Sidebar customization + +Open **Settings → Appearance → Left sidebar** to pin Agents, +assistants, MiniApps, installed/discoverable Skills, or Insights. Available pages +use capsules; select one to pin it. Move pinned pages up/down, remove +them, or restore the default Home + Task Board layout. Home remains available. +The selected pinned subpage gets its own highlight instead of also highlighting Home. + +Changes apply immediately and persist on this controller across workspaces and +device switches. An unsupported or temporarily missing destination keeps its +position with an unavailable state; returning to a supported device or restoring +the contribution makes it usable again. This layout preference does not enable +host capabilities. Skill management/discovery currently requires the local Desktop. + ## MiniApp navigation shortcuts In **MiniApps → Installed**, open an app's **More actions** menu and choose **Add to left navigation**. The same action is available on installed-app detail -pages. Shortcuts appear after **Task Board**, in the order added, and open the +pages. Shortcuts appear after the customized page entries, in the order added, and open the app in a tab. Each shortcut shows only the app icon and name. Its remove button appears on hover or keyboard focus within the same navigation row. Use that button or the app's navigation action to remove it without uninstalling the app. @@ -380,7 +394,8 @@ ControlHub, while native apps and OS dialogs use ComputerUse. ## External Import status checks -Open a disposable local workspace in Desktop and select an app under External Import. +Open a disposable local workspace in Desktop and select an app under **Settings → Tools & integrations → External import**. +The page retains application selection and import tables inside the settings layout; it no longer has a separate menu entry. Use test content or reversible imported copies. Category discovery coverage is independent of copy import and execution support. For Codex, Zcode, Claude Code, OpenCode, Pi, DeepSeek Harness and Cursor, enable **Automatically discover sessions** in Ecosystem @@ -477,3 +492,34 @@ pnpm run capabilities:test Fixtures cover scan failures, legacy hosts with missing facts, and conflicts without damaging real configuration. Remote/Peer frontend fixtures verify presentation and import gates; actual SSH, cross-device, IM remote control and Detached Dispatch require separate environment testing. + +### Session trajectory + +The chart icon in the conversation header opens a three-lane trajectory for user +input, model rounds and tool calls. Search, previous/next result and close controls +share the session's top tab bar. Blank clicks clear selections while keeping the +trajectory and search open; the close button or Escape dismisses them. +Hover or keyboard focus previews one node while dimming the others; clicking or +pressing Enter locates its FlowChat item and opens the reusable details panel. +The reference line highlights the crossed node without dimming its neighbors. +Selected nodes retain their emphasis until another selection or a blank click. +Details use the shared PageHeader, FieldGroup, Disclosure and ScrollArea components. +Timing appears in the header, followed by parameter and result chapters without +inner tabs. Search reveals its matched chapter within the panel. Large output is +disclosed incrementally, and missing timing remains unknown rather than a measured zero. +Read-only fields align labels and values, stack on narrow panels, and give long +values the full row width. Nested data expands within its field group. + +Drag across the rail, Shift-select, or adjust the range handles with the arrow +keys to select an inclusive range. Search navigation stays within that range; +the details panel shows counts, recorded cumulative durations and endpoint actions. +Parallel model/tool durations are sums, not elapsed wall time. + +Only visible trajectory markers are mounted. History summaries are read in +bounded pages without materializing the whole transcript; selected bodies use +existing scoped history readers. SSH workspace requests retain the owning workspace +identity. Peer hosts negotiate `session_trajectory_v1`; older hosts expose an explicit +partial view of available events. Peer detail bodies remain owned by the canonical +record replica. This observer UI adds no runtime or filesystem dependency for +Remote Control or Detached Dispatch jobs. Automated fixtures simulate these routing +and device-switch contracts; live remote and native visual checks remain manual. diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx index 08ab474d1b..5cbd7bfb11 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx @@ -23,6 +23,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t }) })); vi.mock('@/infrastructure/i18n/hooks/useI18n', () => ({ useI18n: () => ({ t }) })); vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t }) })); vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn() }) })); +vi.mock('@/shared/notification-system', () => ({ notificationService: { error: vi.fn() } })); vi.mock('@/infrastructure/peer-device/pickWorkspaceDirectory', () => ({ pickWorkspaceDirectory: state.pickDirectory })); vi.mock('@/infrastructure/api', () => ({ globalAPI: {}, workspaceAPI: {} })); vi.mock('@/app/services/createProject', () => ({ createProject: state.createProject })); @@ -72,6 +73,7 @@ vi.mock('@/features/ssh-remote', () => ({ import MainNav from './MainNav'; import { useWorkspaceSessionViewStore } from './workspaceSessionView'; import { useMiniAppNavigationStore } from '@/app/scenes/miniapps/miniAppNavigationStore'; +import { sidebarPreferences } from '@/app/navigation/sidebarPreferences'; describe('project creation entry points', () => { let root: Root; @@ -111,6 +113,7 @@ describe('project creation entry points', () => { resetDeviceSurfaceForTest(); useMiniAppNavigationStore.setState({ pinnedAppIdsBySurface: {} }); state.activeTabId = 'personal-overview'; + sidebarPreferences.setState({ pinnedIds: ['builtin.todos'], storageIssue: null }); state.createProject.mockResolvedValue(true); state.options = {}; for (const source of ['ssh', 'wsl', 'localDocker', 'remoteDocker']) { @@ -148,6 +151,16 @@ describe('project creation entry points', () => { expect(host.querySelector('[data-testid="nav-personal-overview"]')!.getAttribute('data-openbitfun-state')).toBe(''); }); + it('highlights a pinned Home child instead of Home, and falls back after unpinning', async () => { + state.activeTabId = 'agents'; + act(() => sidebarPreferences.getState().setPinned('builtin.agents', true)); + await render(); + expect(host.querySelector('[data-testid="nav-personal-overview"]')!.getAttribute('data-openbitfun-state')).toBe(''); + expect(host.querySelector('[data-testid="nav-builtin.agents"]')!.getAttribute('data-openbitfun-state')).toBe('active'); + act(() => sidebarPreferences.getState().setPinned('builtin.agents', false)); + expect(host.querySelector('[data-testid="nav-personal-overview"]')!.getAttribute('data-openbitfun-state')).toBe('active'); + }); + it.each([true, false])('reveals project groups only after successful creation (success=%s)', async succeeded => { useWorkspaceSessionViewStore.getState().setGrouping('all'); state.createProject.mockResolvedValueOnce(succeeded); diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index c003366b81..8d4607b681 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -42,7 +42,8 @@ import { workspacePresetHost } from '@/features/ssh-remote/useWorkspaceSourceOpt import { isSshAuthenticationError, workspaceSourceErrorKey } from '@/features/ssh-remote/workspaceSourceError'; import type { WorkspaceConnectionTarget } from '@/features/ssh-remote/types'; import { openGlobalSearch } from '@/app/global-search/globalSearchStore'; -import { activateProductAction } from '@/app/global-search/productActionActivator'; +import { useSidebarNavigation } from '@/app/navigation/useSidebarNavigation'; +import PinnedNavigationItems from './components/PinnedNavigationItems'; import { createProject, type ProjectDraft, type ProjectFolder } from '@/app/services/createProject'; import { useWorkspaceSessionViewStore } from './workspaceSessionView'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; @@ -76,7 +77,8 @@ const MainNav: React.FC = () => { const { openScene } = useSceneManager(); const activeTabId = useSceneStore(s => s.activeTabId); const { pinnedAppIds } = useMiniAppNavigation(); - const isOverviewActive = typeof activeTabId === 'string' && ( + const sidebarNavigation = useSidebarNavigation(); + const isOverviewActive = !sidebarNavigation.selectedId && typeof activeTabId === 'string' && ( ['personal-overview', 'assistant', 'profile', 'agents', 'skills', 'miniapps'].includes(activeTabId) || (activeTabId.startsWith('miniapp:') && !pinnedAppIds.includes(activeTabId.slice('miniapp:'.length))) ); @@ -212,13 +214,7 @@ const MainNav: React.FC = () => { } }, [sshRemote, remoteFolderTarget, finishRemoteFolderPick]); - const handleOpenTodos = useCallback(() => { - void activateProductAction('surface.todos.open'); - }, []); - const addSessionGroupTooltip = t('nav.tooltips.addSessionGroup'); - const taskBoardLabel = t('nav.items.todos'); - const isTaskBoardActive = activeTabId === 'todos'; return ( <> { - - - - - )} - selected={isTaskBoardActive} - > - {taskBoardLabel} - - + {pinnedAppIds.length > 0 && } diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 2509557248..510cfe2903 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1557,36 +1557,6 @@ $_section-header-height: 22px; } } -.openbitfun-nav-panel__appearance-menu-trigger { - &.is-open { - background: var(--openbitfun-color-action-neutral-surface); - } -} - -.openbitfun-nav-panel__appearance-menu-current { - display: block; - max-width: 82px; - overflow: hidden; - white-space: nowrap; -} - -/* Positioning and open-state composition only; the design-system Menu owns chrome. */ -.openbitfun-nav-panel__appearance-submenu { - position: fixed; - max-width: calc(100vw - 16px); - max-height: calc(100vh - 16px); - z-index: calc(var(--openbitfun-layer-popover) + 1); - animation: openbitfun-nav-popover-in 150ms cubic-bezier(0.23, 1, 0.32, 1) both; - - &[data-placement='right'] { - transform-origin: top left; - } - - &[data-placement='left'] { - transform-origin: top right; - } -} - // ────────────────────────────────────────────── // Device overview — one compact model, local and connected states // ────────────────────────────────────────────── @@ -2819,8 +2789,7 @@ $_section-header-height: 22px; color 120ms ease; } -.openbitfun-nav-panel__footer-menu, -.openbitfun-nav-panel__appearance-submenu { +.openbitfun-nav-panel__footer-menu { animation: openbitfun-nav-popover-in 150ms cubic-bezier(0.23, 1, 0.32, 1) both; &.is-closing { @@ -2874,7 +2843,6 @@ $_section-header-height: 22px; @media (prefers-reduced-motion: reduce) { .openbitfun-nav-panel__footer-menu, - .openbitfun-nav-panel__appearance-submenu, .openbitfun-device-overview { animation: none; } diff --git a/src/web-ui/src/app/components/NavPanel/appearance.ts b/src/web-ui/src/app/components/NavPanel/appearance.ts index d117ced1af..f81c097564 100644 --- a/src/web-ui/src/app/components/NavPanel/appearance.ts +++ b/src/web-ui/src/app/components/NavPanel/appearance.ts @@ -15,7 +15,6 @@ export const navPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'todoEntry', propertyProfile: 'control', visualRole: 'control' }, { id: 'topActions', visualRole: 'toolbar' }, { id: 'topAction', propertyProfile: 'control', visualRole: 'control' }, - { id: 'topActionUnseen', propertyProfile: 'paint', visualRole: 'decoration' }, { id: 'section', visualRole: 'content' }, { id: 'stickySectionHeader', visualRole: 'toolbar', continuityGroup: 'nav-panel' }, { id: 'sectionContent', visualRole: 'content' }, diff --git a/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.test.tsx b/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.test.tsx deleted file mode 100644 index 4f07a9f780..0000000000 --- a/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -// @vitest-environment jsdom - -import React, { act, useState } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const selectAppearance = vi.hoisted(() => vi.fn()); -const notifyError = vi.hoisted(() => vi.fn()); - -vi.mock('@/infrastructure/appearance', () => ({ - SYSTEM_APPEARANCE_ID: 'system', - useAppearance: () => ({ - appearances: [ - { - id: 'openbitfun-light', - name: 'OpenBitFun Light', - version: '1.0.0', - mode: 'light', - source: 'builtin', - }, - { - id: 'custom-ocean', - name: 'Ocean', - version: '1.0.0', - mode: 'dark', - source: 'imported', - }, - ], - current: { id: 'openbitfun-light', name: 'OpenBitFun Light' }, - initialized: true, - select: selectAppearance, - selectedAppearanceId: 'openbitfun-light', - status: 'ready', - }), -})); - -vi.mock('@/infrastructure/i18n/hooks/useI18n', () => ({ - useI18n: (namespace: string) => ({ - t: (key: string, options?: { defaultValue?: string }) => { - const translations: Record = namespace === 'settings/application' - ? { - 'appearance.systemAppearance': 'Match system', - 'appearance.presets.openbitfun-light.name': 'Light', - } - : { - 'nav.settingsMenu.theme': 'Theme', - 'nav.settingsMenu.themeConfiguration': 'Theme configuration', - 'nav.settingsMenu.appearanceSwitchFailed': 'Could not switch the theme. Try again.', - }; - return translations[key] ?? options?.defaultValue ?? key; - }, - }), -})); - -vi.mock('@/infrastructure/appearance/runtime/AppearanceOverlayHost', () => ({ - getAppearanceOverlayHost: () => document.body, -})); - -vi.mock('@/shared/notification-system', () => ({ - useNotification: () => ({ error: notifyError }), -})); - -import AppearanceQuickSwitchMenuItem from './AppearanceQuickSwitchMenuItem'; - -describe('AppearanceQuickSwitchMenuItem', () => { - let container: HTMLDivElement; - let root: Root; - let closeParentMenu: ReturnType; - let openAppearanceSettings: ReturnType; - - const Harness = () => { - const [open, setOpen] = useState(false); - return ( - - ); - }; - - beforeEach(() => { - selectAppearance.mockReset().mockResolvedValue(undefined); - notifyError.mockReset(); - closeParentMenu = vi.fn(); - openAppearanceSettings = vi.fn(); - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - callback(0); - return 1; - }); - vi.stubGlobal('cancelAnimationFrame', vi.fn()); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - act(() => root.render()); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - }); - - it('shows the current theme and opens the checked quick-switch submenu only on click', () => { - const trigger = container.querySelector( - '[data-testid="nav-settings-appearance-item"]', - ); - expect(trigger).not.toBeNull(); - expect(trigger?.textContent).toContain('Theme'); - expect(trigger?.textContent).toContain('Light'); - expect(trigger?.getAttribute('aria-haspopup')).toBe('menu'); - - act(() => { - trigger!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); - }); - expect(document.querySelector('[data-testid="nav-settings-appearance-menu"]')).toBeNull(); - - act(() => trigger!.click()); - - const submenu = document.querySelector('[data-testid="nav-settings-appearance-menu"]'); - const selected = document.querySelector( - '[data-testid="nav-settings-appearance-option"][data-appearance-id="openbitfun-light"]', - ); - expect(submenu).not.toBeNull(); - expect(selected?.getAttribute('role')).toBe('menuitemradio'); - expect(selected?.getAttribute('aria-checked')).toBe('true'); - expect(submenu?.textContent).toContain('Match system'); - expect(submenu?.textContent).toContain('Ocean'); - - act(() => trigger!.click()); - expect(document.querySelector('[data-testid="nav-settings-appearance-menu"]')).toBeNull(); - }); - - it('applies a selected theme immediately and closes the parent menu', async () => { - const trigger = container.querySelector( - '[data-testid="nav-settings-appearance-item"]', - ); - act(() => trigger!.click()); - const ocean = document.querySelector( - '[data-testid="nav-settings-appearance-option"][data-appearance-id="custom-ocean"]', - ); - - await act(async () => { - ocean!.click(); - await Promise.resolve(); - }); - - expect(selectAppearance).toHaveBeenCalledWith('custom-ocean'); - expect(closeParentMenu).toHaveBeenCalledOnce(); - }); - - it('keeps full theme configuration available from the submenu', () => { - const trigger = container.querySelector( - '[data-testid="nav-settings-appearance-item"]', - ); - act(() => trigger!.click()); - const configure = document.querySelector( - '[data-testid="nav-settings-appearance-settings"]', - ); - act(() => configure!.click()); - - expect(openAppearanceSettings).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.tsx b/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.tsx deleted file mode 100644 index 969e7244e3..0000000000 --- a/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type KeyboardEvent, - type RefObject, -} from 'react'; -import { createOverlayPortal, OverflowText, Icon, Menu, MenuItem, MenuSeparator } from '@openbitfun/ui'; - -import { - SYSTEM_APPEARANCE_ID, - useAppearance, - type AppearanceCatalogEntry, - type AppearanceSelectionId, -} from '@/infrastructure/appearance'; -import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; -import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; -import { useNotification } from '@/shared/notification-system'; - -interface AppearanceQuickSwitchMenuItemProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onCloseParentMenu: () => void; - onOpenAppearanceSettings: () => void; -} - -interface SubmenuLayout { - left: number; - placement: 'left' | 'right'; - top: number; -} - -const SUBMENU_GAP = 5; -const SUBMENU_FALLBACK_WIDTH = 228; -const SUBMENU_FALLBACK_HEIGHT = 360; -const VIEWPORT_PADDING = 8; - -const clamp = (value: number, min: number, max: number): number => ( - Math.min(Math.max(value, min), Math.max(min, max)) -); - -function useSubmenuLayout( - open: boolean, - anchorRef: RefObject, - submenuRef: RefObject, - layoutRevision: unknown, -): SubmenuLayout | null { - const [layout, setLayout] = useState(null); - - const updateLayout = useCallback(() => { - const anchor = anchorRef.current; - const submenu = submenuRef.current; - if (!anchor || !submenu) return; - - const anchorRect = anchor.getBoundingClientRect(); - const submenuRect = submenu.getBoundingClientRect(); - const submenuWidth = submenuRect.width - || submenu.offsetWidth - || submenu.scrollWidth - || SUBMENU_FALLBACK_WIDTH; - const submenuHeight = submenuRect.height - || submenu.offsetHeight - || submenu.scrollHeight - || SUBMENU_FALLBACK_HEIGHT; - const preferredLeft = anchorRect.right + SUBMENU_GAP; - const opensRight = preferredLeft + submenuWidth <= window.innerWidth - VIEWPORT_PADDING; - const nextLayout: SubmenuLayout = { - left: clamp( - opensRight ? preferredLeft : anchorRect.left - SUBMENU_GAP - submenuWidth, - VIEWPORT_PADDING, - window.innerWidth - submenuWidth - VIEWPORT_PADDING, - ), - placement: opensRight ? 'right' : 'left', - top: clamp( - anchorRect.top - 4, - VIEWPORT_PADDING, - window.innerHeight - submenuHeight - VIEWPORT_PADDING, - ), - }; - - setLayout(current => current?.left === nextLayout.left - && current.top === nextLayout.top - && current.placement === nextLayout.placement - ? current - : nextLayout); - }, [anchorRef, submenuRef]); - - useLayoutEffect(() => { - if (!open) { - setLayout(null); - return; - } - - updateLayout(); - window.addEventListener('resize', updateLayout); - window.addEventListener('scroll', updateLayout, true); - const resizeObserver = typeof ResizeObserver === 'undefined' - ? null - : new ResizeObserver(updateLayout); - if (anchorRef.current) resizeObserver?.observe(anchorRef.current); - if (submenuRef.current) resizeObserver?.observe(submenuRef.current); - - return () => { - window.removeEventListener('resize', updateLayout); - window.removeEventListener('scroll', updateLayout, true); - resizeObserver?.disconnect(); - }; - }, [anchorRef, open, submenuRef, updateLayout]); - - useLayoutEffect(() => { - if (open) updateLayout(); - }, [layoutRevision, open, updateLayout]); - - return layout; -} - -const AppearanceQuickSwitchMenuItem: React.FC = ({ - open, - onOpenChange, - onCloseParentMenu, - onOpenAppearanceSettings, -}) => { - const { t } = useI18n('common'); - const { t: tApplication } = useI18n('settings/application'); - const { error: notifyError } = useNotification(); - const { - appearances, - current, - initialized, - select, - selectedAppearanceId, - status, - } = useAppearance(); - const triggerRef = useRef(null); - const submenuRef = useRef(null); - const focusSubmenuOnOpenRef = useRef(false); - - const getAppearanceDisplayName = useCallback((appearance: AppearanceCatalogEntry) => { - if (appearance.source !== 'builtin') return appearance.name; - const presetId = appearance.id.replace(/^builtin\./, ''); - return tApplication(`appearance.presets.${presetId}.name`, { - defaultValue: appearance.name, - }); - }, [tApplication]); - - const selectedDisplayName = useMemo(() => { - if (selectedAppearanceId === SYSTEM_APPEARANCE_ID) { - return tApplication('appearance.systemAppearance'); - } - const selected = appearances.find(appearance => appearance.id === selectedAppearanceId); - if (selected) return getAppearanceDisplayName(selected); - return current?.name ?? t('nav.settingsMenu.theme'); - }, [appearances, current?.name, getAppearanceDisplayName, selectedAppearanceId, t, tApplication]); - - const submenuLayout = useSubmenuLayout( - open, - triggerRef, - submenuRef, - `${appearances.length}:${selectedAppearanceId}`, - ); - - const focusFirstSubmenuItem = useCallback(() => { - const firstItem = submenuRef.current?.querySelector( - '[data-openbitfun-menu-item]:not(:disabled)', - ); - firstItem?.focus(); - }, []); - - useEffect(() => { - if (!open || !focusSubmenuOnOpenRef.current) return; - focusSubmenuOnOpenRef.current = false; - const frame = window.requestAnimationFrame(focusFirstSubmenuItem); - return () => window.cancelAnimationFrame(frame); - }, [focusFirstSubmenuItem, open]); - - const openFromKeyboard = useCallback(() => { - focusSubmenuOnOpenRef.current = true; - if (open) { - window.requestAnimationFrame(focusFirstSubmenuItem); - } else { - onOpenChange(true); - } - }, [focusFirstSubmenuItem, onOpenChange, open]); - - const handleTriggerKeyDown = (event: KeyboardEvent) => { - if (event.key === 'ArrowRight') { - event.preventDefault(); - event.stopPropagation(); - openFromKeyboard(); - } - }; - - const handleSubmenuKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'ArrowLeft' && event.key !== 'Escape') return; - event.preventDefault(); - event.stopPropagation(); - onOpenChange(false); - triggerRef.current?.focus(); - }; - - const handleSelect = useCallback(async (id: AppearanceSelectionId) => { - try { - await select(id); - onCloseParentMenu(); - } catch { - notifyError(t('nav.settingsMenu.appearanceSwitchFailed'), { duration: 4000 }); - } - }, [notifyError, onCloseParentMenu, select, t]); - - const selectionDisabled = !initialized || status === 'applying'; - - return ( - <> - - - {open && createOverlayPortal( - - - - {appearances.map(appearance => { - const selected = appearance.id === selectedAppearanceId; - return ( - - ); - })} - - - , - getAppearanceOverlayHost(), - )} - - ); -}; - -export default AppearanceQuickSwitchMenuItem; diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index 7c35f3c5c5..e7c20fd3a8 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -1,13 +1,11 @@ import { lazyWithRecovery } from '@/shared/utils/lazyWithRecovery'; import React, { Suspense, useState, useCallback, useEffect, useRef } from 'react'; -import { Cable } from 'lucide-react'; import { createOverlayPortal, Icon, IconButton, Menu, MenuItem, - MenuSection, MenuSeparator, Tooltip, Dialog, @@ -19,8 +17,6 @@ import { createOverlayPortal, import { RetainedMountBoundary } from '@/shared/presence'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { activateProductAction } from '@/app/global-search/productActionActivator'; -import { useSceneStore } from '@/app/stores/sceneStore'; -import { useExternalAppAwareness } from '@/infrastructure/config/components/external-sources/useExternalAppAwareness'; import { useToolbarModeContext } from '@/flow_chat/components/toolbar-mode/ToolbarModeContext'; import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI'; import NotificationButton from '../../TitleBar/NotificationButton'; @@ -31,10 +27,8 @@ import { } from '../../RemoteConnectDialog/remoteConnectDisclaimerStorage'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; -import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; import { PeerConnectionStatus } from '@/infrastructure/peer-device/PeerConnectionStatus'; import DeviceStatusControl from './DeviceStatusControl'; -import AppearanceQuickSwitchMenuItem from './AppearanceQuickSwitchMenuItem'; import { UpdateIndicator } from '@/infrastructure/update/UpdateIndicator'; import { useHasAppUpdate } from '@/infrastructure/update/useHasAppUpdate'; import { UpdateDownloadIndicator } from '@/infrastructure/update/UpdateDownloadIndicator'; @@ -50,16 +44,10 @@ const RemoteConnectDialog = lazyWithRecovery(() => import('../../RemoteConnectDi const PersistentFooterActions: React.FC = () => { const { t } = useI18n('common'); - const isEcosystemCompatibilityActive = useSceneStore(state => state.activeTabId === 'ecosystem-compatibility'); - const hasUnseenEcosystemCompatibility = useExternalAppAwareness(isEcosystemCompatibilityActive); - const ecosystemCompatibilityTooltip = hasUnseenEcosystemCompatibility - ? t('nav.tooltips.ecosystemCompatibilityUnseen') - : t('nav.tooltips.ecosystemCompatibility'); const { enableToolbarMode } = useToolbarModeContext(); const accountIdentity = useAccountIdentity(); const [menuOpen, setMenuOpen] = useState(false); const [menuClosing, setMenuClosing] = useState(false); - const [appearanceSubmenuOpen, setAppearanceSubmenuOpen] = useState(false); const [deviceOverviewOpen, setDeviceOverviewOpen] = useState(false); const menuTriggerRef = useRef(null); const menuPopoverRef = useRef(null); @@ -96,7 +84,6 @@ const PersistentFooterActions: React.FC = () => { }, []); const closeMenu = useCallback(() => { - setAppearanceSubmenuOpen(false); setMenuClosing(true); setTimeout(() => { setMenuOpen(false); @@ -109,7 +96,6 @@ const PersistentFooterActions: React.FC = () => { closeMenu(); } else { setDeviceOverviewOpen(false); - setAppearanceSubmenuOpen(false); setMenuOpen(true); } }; @@ -126,17 +112,6 @@ const PersistentFooterActions: React.FC = () => { void activateProductAction('settings.open'); }, [closeMenu]); - const handleOpenEcosystemCompatibility = useCallback(() => { - closeMenu(); - void activateProductAction('surface.ecosystemCompatibility.open'); - }, [closeMenu]); - - const handleOpenAppearanceSettings = useCallback(() => { - closeMenu(); - useSettingsStore.getState().openPage('application.appearance'); - void activateProductAction('settings.open'); - }, [closeMenu]); - const handleShowAbout = () => { closeMenu(); window.dispatchEvent(new Event('nav:show-about')); @@ -250,12 +225,8 @@ const PersistentFooterActions: React.FC = () => { onKeyDown={(event) => { if (event.key !== 'Escape') return; event.preventDefault(); - if (appearanceSubmenuOpen) { - setAppearanceSubmenuOpen(false); - } else { - closeMenu(); - menuTriggerRef.current?.focus(); - } + closeMenu(); + menuTriggerRef.current?.focus(); }} style={{ top: `${menuLayout?.top ?? 0}px`, @@ -271,33 +242,6 @@ const PersistentFooterActions: React.FC = () => { {t('nav.settingsMenu.floatingWindow')} - - - - - - - { > {t('nav.settingsMenu.feedback')} +