From 70eb44984c407519a3386067d1560b5c8b39bc24 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 7 Aug 2026 17:22:16 +0800 Subject: [PATCH 01/65] refactor(harmonyos): split the app shell into MVVM layers AppRoot had grown into a single runtime object that owned routing, remote transport, conversation state and presentation at once, so every feature change reached across all of them. Split it along explicit boundaries: - pages/runtime for the composition root and lifecycle - pages/viewmodel for controllers and view models - pages/policy for pure decision helpers - pages/actions for the typed intent/action surface handed to components - pages/navigation and pages/layout for route and geometry contracts Components now receive typed action objects instead of reaching into view models, which lets Local and Remote share one conversation shell (ConversationRouteSurface on compact, WideConversationHost on wide). Behaviour changes that came out of the split: - Creating a chat from the "chat" option binds the desktop's assistant workspace first. The desktop ignores workspace_path for Claw sessions and always uses its assistant workspace, so the app used to keep showing the code workspace it was on while the session was actually created elsewhere - the new chat never appeared in the list. - Picking a workspace in the create sheet now pairs it with the code agent, so the picker is honoured instead of being silently dropped. - Compact remote conversations open the sidebar over the chat from a menu button, matching local chats, instead of popping back out of the conversation. The system back gesture still leaves the chat and reveals the drawer. Co-Authored-By: Claude Opus 5 --- src/apps/mobile/harmonyos/AGENTS.md | 38 + .../docs/mvvm-architecture-refactor-design.md | 575 ++++ .../wide-conversation-navigation-design.md | 4 +- .../state => model}/FilePreviewTarget.ets | 0 .../entry/src/main/ets/pages/AppRoot.ets | 6 +- .../actions/AppRootPresentationActions.ets | 152 + .../ConversationIntent.ets | 4 +- .../ConversationIntentDispatcher.ets | 33 +- .../components/AppRootOverlaySurfaces.ets | 185 ++ .../pages/components/AppRootPresentation.ets | 1220 +------- .../main/ets/pages/components/AppSidebar.ets | 289 +- .../components/BitFunAccountLoginPage.ets | 18 +- .../pages/components/ChatMessageBubble.ets | 350 +-- .../pages/components/ChatMessageChrome.ets | 109 + .../pages/components/ChatMessageContent.ets | 111 + .../ets/pages/components/ChatStatusBar.ets | 12 +- .../main/ets/pages/components/ComposerBar.ets | 2 +- .../components/ConnectAccountDevicePage.ets | 245 ++ .../ConnectManualPairingOverlay.ets | 107 + .../main/ets/pages/components/ConnectView.ets | 820 +----- .../components/ConversationLoadingState.ets | 58 + .../components/ConversationRouteSurface.ets | 94 + .../components/ConversationSourceSwitcher.ets | 6 +- .../ets/pages/components/ConversationView.ets | 17 +- .../pages/components/ConversationViewHost.ets | 3 +- .../components/ConversationViewSettings.ets | 2 +- .../pages/components/CreateSessionSheet.ets | 26 +- .../pages/components/DefaultAccountAvatar.ets | 4 +- .../pages/components/FileReferenceCard.ets | 22 +- .../pages/components/GeneralChatHeader.ets | 41 +- .../ets/pages/components/MarkdownContent.ets | 8 +- .../components/ModelServiceSettingsPanel.ets | 40 +- .../ets/pages/components/RemoteChatHeader.ets | 10 + .../components/RemoteControlSettingsSheet.ets | 75 +- .../pages/components/RemoteSessionList.ets | 34 +- .../ets/pages/components/SettingsSheet.ets | 34 +- .../ets/pages/components/SidebarGlyphs.ets | 151 + .../components/StreamingMarkdownContent.ets | 15 +- .../src/main/ets/pages/components/Theme.ets | 2 + .../ets/pages/components/ThinkingBlock.ets | 16 +- .../main/ets/pages/components/ToolGlyphs.ets | 40 + .../components/ToolInteractionPanels.ets | 171 ++ .../ets/pages/components/ToolStatusList.ets | 418 +-- .../pages/components/WideConversationHost.ets | 319 ++ .../components/remote/RemoteSurfaceHost.ets | 368 +++ .../ets/pages/layout/WideLayoutGeometry.ets | 35 + .../navigation}/AppRootRouteState.ets | 19 +- .../ets/pages/navigation/AppRouteContract.ets | 4 + .../ConversationLayoutPolicy.ets | 0 .../ConversationModelPresentationPolicy.ets | 0 .../ConversationSessionFilterPolicy.ets | 0 .../FilePreviewPlacementPolicy.ets | 0 .../{state => policy}/SessionActionPolicy.ets | 0 .../main/ets/pages/runtime/AppRootRuntime.ets | 390 +++ .../runtime/AppRootRuntimeComposition.ets | 803 +++++ .../main/ets/pages/state/AppRootRuntime.ets | 2608 ----------------- .../main/ets/pages/state/AppShellState.ets | 10 + .../ets/pages/state/ConversationCoreState.ets | 191 ++ .../ets/pages/state/ConversationViewState.ets | 52 +- .../main/ets/pages/state/FilePreviewState.ets | 2 +- .../ets/pages/state/GeneralChatPageState.ets | 138 +- .../pages/state/RemoteCreateSessionState.ets | 12 +- .../main/ets/pages/state/RemotePageState.ets | 173 +- .../AppShellViewModel.ets | 2 +- .../viewmodel/ConversationController.ets | 1038 +++++++ .../ConversationViewModel.ets | 0 .../pages/viewmodel/FilePreviewController.ets | 92 + .../GeneralChatConversationViewModel.ets | 18 +- .../RemoteActivityViewModel.ets | 34 +- .../RemoteConnectionController.ets} | 4 +- .../RemoteFilePreviewController.ets | 16 +- .../RemoteSessionViewModel.ets | 88 +- .../RemoteWorkspaceViewModel.ets | 24 +- .../pages/viewmodel/SettingsController.ets | 523 ++++ .../main/ets/services/FileTargetResolver.ets | 2 +- .../MessageFileReferenceProjector.ets | 2 +- .../main/resources/base/element/color.json | 8 + .../main/resources/dark/element/color.json | 8 + .../src/test/AppRootLifecycleUnit.test.ets | 42 +- .../test/AppRootRuntimeStartupUnit.test.ets | 19 +- .../entry/src/test/ArchitectureUnit.test.ets | 13 +- .../src/test/ConversationStateUnit.test.ets | 91 + .../entry/src/test/LifecycleUnit.test.ets | 12 + .../src/test/RemoteControllersUnit.test.ets | 35 +- .../test/TransportAndGeneralChatUnit.test.ets | 110 + 85 files changed, 6848 insertions(+), 6024 deletions(-) create mode 100644 src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md rename src/apps/mobile/harmonyos/entry/src/main/ets/{pages/state => model}/FilePreviewTarget.ets (100%) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{components => actions}/ConversationIntent.ets (94%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => actions}/ConversationIntentDispatcher.ets (67%) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets rename src/apps/mobile/harmonyos/entry/src/main/ets/{services => pages/navigation}/AppRootRouteState.ets (78%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => policy}/ConversationLayoutPolicy.ets (100%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => policy}/ConversationModelPresentationPolicy.ets (100%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => policy}/ConversationSessionFilterPolicy.ets (100%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => policy}/FilePreviewPlacementPolicy.ets (100%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => policy}/SessionActionPolicy.ets (100%) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets delete mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => viewmodel}/AppShellViewModel.ets (98%) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => viewmodel}/ConversationViewModel.ets (100%) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => viewmodel}/GeneralChatConversationViewModel.ets (95%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => viewmodel}/RemoteActivityViewModel.ets (78%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state/RemoteConnectionViewModel.ets => viewmodel/RemoteConnectionController.ets} (99%) rename src/apps/mobile/harmonyos/entry/src/main/ets/{services => pages/viewmodel}/RemoteFilePreviewController.ets (95%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => viewmodel}/RemoteSessionViewModel.ets (74%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => viewmodel}/RemoteWorkspaceViewModel.ets (86%) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index c177c473f..15e9ccc1a 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -2,6 +2,44 @@ These rules apply to all changes under `src/apps/mobile/harmonyos`. +## MVVM Refactor Boundaries + +This app has one `entry` module, so MVVM is the file-organization boundary for +the module. Keep the official responsibilities explicit: + +- Model/services own data access, persistence, transport, and business logic; + they do not import views or page components. +- Views own presentation and user input; they consume projected state and emit + intents/events rather than calling services directly. +- ViewModels bridge services and views by owning feature state, projecting data, + and handling intents. ViewModels must not import components. + +The following constraints are enforced incrementally by +`pnpm run harmony:architecture` (the runtime behavior checks remain in +`entry/src/test/ArchitectureUnit.test.ets`): + +1. `services/**` must not import `../pages/`. +2. `pages/components/**` must not import `pages/viewmodel/`; imports of + `pages/state/` and `pages/policy/` are allowed for observable state and pure + policies. +3. The page dependency graph must remain acyclic; ViewModels must not depend on + components. +4. Actions and Hooks use typed interfaces with object literals. Do not add + position-dependent callback constructors. +5. New components use `@ComponentV2`; do not add V1 `@Component`, `@State`, + `@Prop`, `@Link`, or `@Watch` declarations. `@BuilderParam` remains supported. +6. General Chat and Remote Chat shared observable fields belong to + `pages/state/ConversationCoreState.ets`. Page-specific state objects compose + that core and must not redeclare the shared `@Trace` fields. + +The current local HarmonyOS verification loop is: + +```bash +source scripts/ohos-env.sh +"$HVIGORW" --mode module -p product=default -p module=entry@default assembleHap --no-daemon +"$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon +``` + ## Visual reference fidelity - Before drawing a system glyph, text approximation, or new bitmap, search the existing HarmonyOS media resources and the approved desktop reference images. Reuse the established asset when one exists. diff --git a/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md b/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md new file mode 100644 index 000000000..8185fa3cf --- /dev/null +++ b/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md @@ -0,0 +1,575 @@ +# HarmonyOS 端 MVVM 架构重构设计 + +Date: 2026-08-06 + +Status: Implementation in progress; S0-S5 and S7 are complete, while S6 component decomposition and the wide-screen visual matrix remain pending + +Scope: `src/apps/mobile/harmonyos/entry/src/main/ets` + +Baseline: commit `6c35485bb`(窄屏 Local/Remote 统一完成后) + +Reference: 华为官方文档 +[MVVM模式(状态管理V2)](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V13/arkts-mvvm-v2-V13)、 +[MVVM模式(V1)](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-mvvm)、 +[状态管理(V1)](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-state-management-v1) + +Related designs: + +- [`adaptive-conversation-ui-redesign.md`](adaptive-conversation-ui-redesign.md) +- [`wide-conversation-navigation-design.md`](wide-conversation-navigation-design.md) +- [`responsive-file-preview-design.md`](responsive-file-preview-design.md) +- [`native-code-preview-implementation-design.md`](native-code-preview-implementation-design.md) + +本文只负责**代码结构**,不改变任何用户可见行为。上述四篇设计继续负责路由合同、折痕几何、文件预览 placement 和会话 UI/UX;本文的每一个阶段都以"这些文档描述的行为在真机上完全不变"为验收前提。发生冲突时,以现有行为文档为准,重构方案让路。 + +--- + +## 0. 结论摘要 + +- **架构基准**:MVVM 是鸿蒙官方文档明确定义的模式,官方把它定位为**单模块内的文件组织方式**;整个应用的模块化官方推荐三层架构(products / features / commons)。本项目 `build-profile.json5` 只有一个 `entry` 模块,正落在 MVVM 覆盖的范围内——**MVVM 是本次重构正确且足够的框架,三层架构不在本次范围**。 +- **好消息**:ViewModel 层已经是干净的。7 个 `*ViewModel` 共 1379 行,**没有任何一个 import `components/`**。MVVM 里最难守住的一条,这里已经守住了。 +- **重构结果**:`services/` → `pages/`、`components` → `viewmodel`、`viewmodel` → `components` 当前均为零;运行时组合根已拆为 `AppRootRuntime` 与 `AppRootRuntimeComposition`,特性行为由四个 Controller 持有。 +- **一条被更正的判断**:初版诊断把"10 个 `components/*` import `../state/`"列为分层违规,**这是错的**,详见 §3.4。 +- **状态管理范式统一到 V2**:基线有 15 个 V1 struct(5114 行)与 19 个 V2 struct 混用;S5 已将这 15 个组件全部迁移到 V2。V2 是官方对新项目的推荐范式,也是官方 MVVM 示例的形式,详见 §2.8 与 §5 的 S5 阶段。 +- **实施方式**:S0–S7 八个阶段,每个阶段独立可发布、可回滚,前三个阶段零行为变更。 + +--- + +## 1. 架构基准 + +### 1.1 官方 MVVM 的三条职责界定 + +引自华为官方文档: + +- **model** —— 负责数据的获取和存储以及业务逻辑,**不与 view 关联**; +- **view** —— 负责界面展现和用户输入,**不与 model 关联**; +- **viewmodel** —— 作为连接二者的桥梁,负责将 model 数据转为 view 数据并管理界面状态。 + +官方 V2 示例的绑定形式是 `@ComponentV2` + `@Local` 持有 ViewModel 实例。 + +本文后续所有"违规"判定,都直接引用上面三句,不引入本文自创的架构偏好。 + +### 1.2 范围界定:MVVM vs 三层架构 + +官方对二者的分工是明确的: + +> MVVM 的目录组织方式一般适用于**单个模块内**的文件组织;为了更好地适配复杂应用开发,建议采用**三层架构**对**整个应用**功能进行模块化。 + +| 层级 | 编译产物 | 依赖约束 | +| --- | --- | --- | +| products(产品定制层) | Entry HAP | 可依赖 features / commons,禁止横向调用 | +| features(基础特性层) | HAR / HSP | 可依赖 commons,避免反向依赖 products | +| commons(公共能力层) | HAR / HSP | 不可依赖上层 | + +**本项目现状**:`build-profile.json5` 的 `modules` 只有 `entry` 一项,`compatibleSdkVersion 6.0.1(21)` / `targetSdkVersion 6.1.1(24)`。单模块 = MVVM 的适用范围。 + +**三层架构的引入时机**(记录,本次不做):当需要为不同设备形态提供差异化入口(折叠屏 / 平板 / 车机各自的 Entry HAP),或 `services/` 需要被鸿蒙端之外复用时,才是把 `services/` 抽成 commons HAR、把会话/Remote 抽成 features HSP 的时机。在只有一个 entry 的现在做这件事,只增加构建复杂度,不带来收益。 + +### 1.3 ArkTS/ArkUI 层面必须遵守的既有教训 + +这些是本模块已经付出过代价的约束,重构中任何一步都不得违反: + +1. **`@Builder` 的值参数不具备响应式**。只有按引用传入的单个对象参数才会驱动重渲染;builder 内部读 `this.` 才是可靠的。拆分 builder 时,凡是原先从父 builder 传入的宽度、来源等标量,一律改为在子 builder 内部读状态。 +2. **`NavPathStack` 不可观测**。任何存活于 `Navigation` 之外的界面(抽屉是典型)都不能靠它驱动刷新,必须消费 `AppShellState.activeRoute` 这个 `@Trace` 镜像。该镜像由 `AppShellViewModel.syncActiveRoute()` 统一维护,**新增导航路径必须经由 `AppShellViewModel`**。 +3. **V1 / V2 混用现状**:`@Component/@State/@Prop` 与 `@ComponentV2/@Local/@Param/@Event` 并存。本次**全量迁移到 V2**,范式统一后 §1.3.1 和 §1.3.2 两条约束的心智负担也随之下降(V2 的观测边界比 V1 明确)。分布数据见 §2.8,实施见 §5 的 S5 阶段。 + +--- + +## 2. 现状测量 + +以下全部为实测值,非估算。 + +### 2.1 规模基线 + +| 目录 | 文件数 | 行数 | +| --- | --- | --- | +| `pages/components` | 39 | 14518 | +| `services`(含 `general-chat` 21 / 3296) | 51 | 7492 | +| `pages/state` | 21 | 5645 | +| `i18n` | — | 581 | +| `model` | — | 465 | +| `pages/navigation` | — | 110 | +| 测试 `entry/src/test` | 8 | 6612 | + +### 2.2 `pages/state/` 的真实构成(一个目录装了三层) + +| 类别 | 文件 | 行数 | +| --- | --- | --- | +| ViewModel | `AppShellViewModel` 98、`ConversationViewModel` 22、`GeneralChatConversationViewModel` 336、`RemoteActivityViewModel` 163、`RemoteConnectionViewModel` 353、`RemoteSessionViewModel` 236、`RemoteWorkspaceViewModel` 171 | 1379 | +| State(`@ObservedV2` 绑定对象) | `AppShellState` 58、`ConversationViewState` 120、`FilePreviewState` 107、`GeneralChatPageState` 200、`RemoteCreateSessionState` 113、`RemotePageState` 373 | 971 | +| Policy(纯逻辑,零 `@Trace`) | `ConversationLayoutPolicy` 156、`FilePreviewPlacementPolicy` 185、`ConversationModelPresentationPolicy` 82、`ConversationSessionFilterPolicy` 51、`SessionActionPolicy` 31 | 505 | +| God Facade | `AppRootRuntime` | 2608 | +| 其他 | `ConversationIntentDispatcher`、`FilePreviewTarget` 等 | 约 182 | + +### 2.3 两个引力井的内部构成 + +**`AppRootPresentation.ets`(1449 行)**——可分离,各段落关注点互不相干: + +| 段落 | 行数 | 性质 | +| --- | --- | --- | +| 7 个 action DTO 定义(L62–270) | 209 | 属于 model 定义,不该在 view 文件里 | +| Remote UI builders | 305 | 一个独立特性面 | +| Remote 辅助方法 | 143 | 同上 | +| 宽屏几何计算 | 179 | 纯计算,可脱离 UI,当前零单测覆盖 | +| 宽屏 builders | 275 | 一个独立布局面 | + +共 25 个 `@Builder`、约 50 个私有方法、21 个 `@Local`(其中 13 个属于宽屏几何、8 个属于 Remote 过滤/元数据)。两组 `@Local` 混在同一 struct 内,意味着改宽屏分栏宽度会连带触发 Remote 过滤区重算。 + +**`AppRootRuntime.ets`(2608 行)**——性质不同,是"所有特性的门面开在同一个类上": + +- 183 个方法级条目,约 101 个 public,其中 **45 个是一行转发**; +- 75 个 import; +- 字段初始化块从 L140 延伸到 L761(621 行); +- 单个方法最长 `selectCloudAccountDevice` 91 行。 + +### 2.4 接线代码 + +12 个 `*Hooks` / `*Actions` 类:定义 412 行,在 `AppRootRuntime` 中的构造点 235 行,合计约 **650 行纯接线**。 + +其中 7 个定义在 `AppRootPresentation.ets` 内(L62–270,209 行)。构造点规模:`AppRootPresentationActions` 96 行、`RemoteSessionViewModelHooks` 46 行、`ConversationIntentDispatcherHooks` 39 行、两个 Hooks 各 23 行、一个 8 行。 + +全部为**位置参数构造**: + +```ts +new AppRootPresentationActions(a, b, c, d, /* …共 96 行实参 */) +``` + +代价不只是行数——新增一个回调要同步改三处(DTO 定义、构造点、消费点),且位置参数在 ArkTS 里没有编译期的名字保护:两个相邻的同签名回调若被调换顺序,编译通过、运行时行为错乱。这是本模块唯一一类"改对了也无法在编译期确认"的修改。 + +### 2.5 会话状态的重复 + +`GeneralChatPageState`(200)与 `RemotePageState`(373)有**约 15 个字段同名同义**。为了让上层统一消费,又长出两层扇入扇出: + +- `services/AppRootRouteState.ets`(88 行)——存在的唯一理由是在两者之间搬数据; +- `ConversationViewState.project(route, remote, general, …)`——再做一遍同样的归约; +- 分散各处的 `compact` 布尔与 `if (source === General)` 分支。 + +后果:每新增一项会话能力(附件、引用、重发……),要在两个 State 各写一次,再在两个投影层各接一次。 + +### 2.6 组件层 + +内联 glyph / icon builder 共 **538 行**,分布在 10 个文件:`AppSidebar` 179、`ConnectView` 99、`ToolStatusList` 91、`ConversationView` 61、`ChatMessageBubble` 35、`SessionActionSurface` 19、`CreateSessionSheet` 18,`ComposerBar` / `RemoteCreateSessionView` / `ChatTimeline` 各 12。 + +第二梯队大结构体:`ToolStatusList` 1442 行 / 16 builders、`ConnectView` 1344 / 24、`ChatMessageBubble` 1246 / 18、`AppSidebar` 908 / 29。 + +### 2.7 现有安全网 + +`entry/src/test/` 共 6612 行 hypium 用例: + +| 文件 | 行数 | +| --- | --- | +| `RemoteControllersUnit` | 2177 | +| `TransportAndGeneralChatUnit` | 1255 | +| `LocalTestFixtures` | 1078 | +| `ConversationStateUnit` | 1057 | +| `LifecycleUnit` | 748 | +| `AppRootLifecycleUnit` | 129 | +| `ArchitectureUnit` | 95 | +| `AppRootRuntimeStartupUnit` | 51 | + +本地运行方式(已实测通过,BUILD SUCCESSFUL 11s,报告落在 `entry/.test/default/outputs/test/reports/`): + +```bash +source scripts/ohos-env.sh +"$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon +``` + +注:现有 `ArchitectureUnit`(95 行)测的是**行为**(生成号失效、时间线归约、路由栈不变量),不是分层。分层目前无任何自动化约束。 + +### 2.8 V1 / V2 范式分布(重构前基线) + +**结构体**:V1(`@Component`)15 个,共 **5114 行**;V2(`@ComponentV2`)19 个。 + +**装饰器用量**: + +| V1 | 次数 | V2 | 次数 | +| --- | --- | --- | --- | +| `@Prop` | 79 | `@Param` | 155 | +| `@State` | 53 | `@Local` | 62 | +| `@BuilderParam` | 7 | `@Event` | 104 | +| `@Watch` | 4 | `@Trace` | 99 | +| `@Link` | 2 | `@ObservedV2` | 5 | +| `@Observed` / `@ObjectLink` / `@Provide` / `@Consume` / `@StorageLink` / `@StorageProp` | 0 | `@Monitor` | 4 | + +(`@BuilderParam` 在 V1 与 V2 中均受支持,不属于迁移面。) + +**V1 文件清单与迁移面**: + +| 文件 | 行数 | `@State` | `@Prop` | `@Link` | `@Watch` | +| --- | --- | --- | --- | --- | --- | +| `ConnectView.ets` | 1344 | 12 | 16 | — | — | +| `AppSidebar.ets` | 908 | 7 | 11 | — | — | +| `RemoteControlSettingsSheet.ets` | 872 | 13 | 12 | — | 1 | +| `ModelServiceSettingsPanel.ets` | 662 | 10 | 5 | — | — | +| `SettingsSheet.ets` | 297 | 4 | 8 | — | — | +| `CreateSessionSheet.ets` | 226 | — | 4 | 2 | — | +| `MarkdownContent.ets` | 199 | — | 1 | — | — | +| `BitFunAccountLoginPage.ets` | 146 | 5 | — | — | — | +| `StreamingMarkdownContent.ets` | 142 | 1 | 3 | — | 3 | +| `FileReferenceCard.ets` | 85 | — | 8 | — | — | +| `ThinkingBlock.ets` | 67 | 1 | 5 | — | — | +| `ChatStatusBar.ets` | 60 | — | 4 | — | — | +| `AppRoot.ets` | 48 | — | — | — | — | +| `ConversationSourceSwitcher.ets` | 40 | — | 1 | — | — | +| `DefaultAccountAvatar.ets` | 18 | — | 1 | — | — | + +**集中度**:前 4 个文件占 3786 行(V1 总量的 74%)、86 个 V1 状态装饰器(占 65%)。其中 `ConnectView` 与 `AppSidebar` 同时也是 S6 拆分的目标,可就近编排。 + +**当前是否已有跨范式错误用法**:已逐文件核查,**没有**。5 个 `@ObservedV2` 类(`AppShellState`、`RemotePageState`、`GeneralChatPageState`、`RemoteCreateSessionState`、`FilePreviewState`)**没有任何一处被 V1 的 `@State` / `@Prop` / `@Link` 持有**——官方不支持 `@ObservedV2` 对象走 V1 观测机制,这条目前没有被踩到。 + +所以全量迁移 V2 **不是在修复既有 bug,而是在消除一类风险**:只要 V1 struct 还在,任何一次后续改动都可能把某个 `@ObservedV2` 对象传进 V1 的 `@Prop`,届时得到的是"编译通过、界面不刷新"——与本模块此前踩过的抽屉不刷新(§1.3.2)完全同型、且同样难以定位的故障。 + +--- + +## 3. 诊断 + +### 3.1 符合官方定义的部分 + +- **ViewModel 层是干净的**:7 个 VM 共 1379 行,零 import `components/`。ViewModel 完全不知道 UI 存在。 +- **Policy 层是纯的**:5 个 Policy 共 505 行,零 `@Trace` / 零 `@ObservedV2`,可直接单测。 +- **已有一处标准 MVVM 三件套**:`ConversationViewState`(投影,120)→ `ConversationViewHost`(哑视图,91)→ `ConversationIntent` / `ConversationIntentDispatcher`(意图,120)。**这是本次重构要推广的形状,不需要发明新范式。** + +### 3.2 硬违规(按 §1.1 官方定义判定) + +| 官方职责 | 违规 | 证据 | +| --- | --- | --- | +| model **不与 view 关联** | `services/` → `pages/` 反向依赖 | `AppRootRouteState`、`FileTargetResolver`、`RemoteFilePreviewController`、`MessageFileReferenceProjector` 共 4 个文件 import `../pages/` | +| view **不与 model 关联** | view 文件持有 model 定义,导致真实模块环 | `AppRootPresentation.ets` L62–270 定义 209 行 action DTO → `AppRootRuntime` 反向 import `AppRootPresentation` | +| viewmodel 是**桥梁** | `AppRootRuntime` 不是桥梁,是 God Facade | 2608 行 / 101 public / 45 一行转发 / 621 行字段初始化块;所有 view 绑到同一个巨型对象,而非各自绑到所属特性的 VM | + +### 3.3 结构性问题(不算违规,但是主要成本来源) + +1. **接线子系统化**(§2.4,约 650 行)——位置参数构造带来无编译期保护的修改风险。 +2. **会话状态双份实现**(§2.5)——每项能力写四遍。 +3. **目录命名说谎**(§2.2)——`pages/state/` 一个目录装了 ViewModel / State / Policy / God Facade 四类东西,"这个文件属于哪一层"无法从路径判断,也导致分层断言写不出来。 +4. **组件层关注点混合**(§2.6)——538 行内联图标 + 四个千行级结构体。 + +### 3.4 更正:一条被推翻的初版判断 + +初版诊断把 **"10 个 `components/*` import `../state/`" 列为分层被打穿。这个判断是错的**,此处保留记录以免后续重复犯错。 + +逐文件查证结果——这 10 个文件 import 的**全部是 State 类与 Policy 类,没有一个 import `*ViewModel`**: + +``` +AppShell.ets → AppShellState +AppSidebar.ets → SessionActionPolicy +ConversationViewHost.ets → ConversationViewState +ComposerBar.ets → ConversationModelPresentationPolicy +FilePreviewSurface.ets → FilePreviewState +ConversationIntent.ets → FilePreviewTarget +ConversationViewSettings → ConversationSessionFilterPolicy +RemoteSessionList.ets → SessionActionPolicy, ConversationSessionFilterPolicy +RemoteCreateSessionView → RemoteCreateSessionState +AppRootPresentation.ets → AppShellState 等 6 个 State/Policy +``` + +View 持有 `@ObservedV2` 状态对象**正是 ArkUI V2 官方推荐的绑定方式**,不是违规。真正的问题是 §3.3 第 3 条:目录名叫 `state`,内容却是四层,让合规的 import 看起来像违规。 + +**因此 S6 的目标已相应修正**:从"切断 `components → state` 的 import"改为"消除内联图标与多关注点混合"。 + +--- + +## 4. 目标结构 + +依赖单向向下,`pages/state/` 按真实层次拆开: + +``` +pages/ + ├─ AppRoot.ets @Entry,组合根 + ├─ actions/ 所有 Actions/Hooks 接口定义(从 view 文件搬出,环即断) + ├─ viewmodel/ 7 个 *ViewModel + 按特性拆出的 Controller + ├─ state/ 纯 @ObservedV2 绑定对象 + ├─ policy/ 纯逻辑,无装饰器,全部可单测 + ├─ layout/ WideLayoutGeometry 等纯几何计算 + ├─ navigation/ AppRouteContract(叶子) + └─ components/ 哑视图 + Glyphs 图标库 +services/ model 层:领域与传输,禁止 import ../pages +model/ i18n/ 叶子 +``` + +**五条硬约束**(S0 写入 `AGENTS.md` 并以"已知清单"模式开始由 `ArchitectureUnit` 拦截新增违规;第 5 条在 S5 完成后转为强制,第 1–3 条在 S7 完成后转为强制): + +1. `services/**` 不得 import `../pages/`; +2. `pages/components/**` 不得 import `pages/viewmodel/`(import `state/` `policy/` 合法); +3. 不存在任何模块环,`viewmodel → components` 方向禁止; +4. Actions/Hooks 一律 `interface` + 对象字面量,禁止位置参数构造; +5. **组件一律 `@ComponentV2`**,禁止新增 `@Component` / `@State` / `@Prop` / `@Link` / `@Watch`(`@BuilderParam` 不在此列,V2 亦支持)。 + +**每个特性面的标准形状**(推广 §3.1 已有的三件套): + +``` +XxxViewState 投影:把 model 数据转成 view 数据 +XxxHost 哑视图:只接 @Param 和回调 +XxxIntent 意图:view 向上表达"用户想做什么" +XxxViewModel 桥梁:持有 state、消费 services、处理 intent +``` + +--- + +## 5. 分阶段方案 + +按"风险调整后收益"排序。S0–S2 零行为变更。每阶段独立可发布、可回滚。 + +### S0 · 立规则与护栏(0.5 天,零行为变更) + +**做什么** + +1. 把 §1.1 官方三条职责、§1.2 范围界定、§4 五条硬约束写入 `src/apps/mobile/harmonyos/AGENTS.md`; +2. 把 §2.7 的本地测试命令补进 `AGENTS.md`(目前未文档化); +3. 扩展 `ArchitectureUnit.test.ets`,新增两组源文件扫描断言,均采用**"已知清单"模式**——断言"当前违规集合 == 登记清单",从此新增违规立即失败,存量按阶段递减: + - 分层断言:登记当前 5 处(`services → pages` 4 处 + `runtime → presentation` 1 处),S7 清零; + - **范式断言**:登记当前 15 个 V1 文件(§2.8 清单),S5 清零。这一条从 S0 当天起就阻止新增 V1 组件,避免迁移期间边迁边长。 + +**为什么先做**:规则来自官方文档,不需要团队内部论证;两份清单让后续每阶段的进度可测,且"只减不增"是机器保证的。 + +**风险**:无。不触碰产物代码。 + +--- + +### S1 · 从 view 中取出 model 定义,断环 + 拆分引力井(1–2 天,零行为变更) + +**做什么** + +1. **7 个 action DTO(L62–270,209 行)→ `pages/actions/`**。单独这一步就消掉硬违规 ② 与循环依赖,建议独立成第一个 commit。 +2. Remote builders + helpers(448 行)→ `pages/components/remote/RemoteSurfaceHost.ets`,带走 8 个 Remote `@Local`。 +3. 宽屏几何(179 行)→ `pages/layout/WideLayoutGeometry.ets`,纯函数,**顺带补单测**(当前零覆盖)。宽屏 builders 带走 13 个几何 `@Local`。 +4. `pages/state/` 按 §4 拆成 `viewmodel/` `state/` `policy/`——纯改目录与 import 路径,零逻辑改动,但让 S0 的断言写得出来。 + +目标:`AppRootPresentation.ets` 从 1449 行收敛到约 300 行的装配壳。 + +**实际结果(2026-08-07)**:7 组 action DTO 已迁入 `pages/actions/`;Remote、 +窄屏路由、宽屏会话与根级 overlay 分别由 `RemoteSurfaceHost`、 +`ConversationRouteSurface`、`WideConversationHost`、`AppRootOverlaySurfaces` +持有。宽屏几何已迁入 `pages/layout/WideLayoutGeometry.ets`,并由 +`ArchitectureUnit` 覆盖关键几何约束。`AppRootPresentation.ets` 从基线 1449 行 +收敛到 406 行,保留响应式测量、`Navigation`、compact preview overlay、Remote +settings sheet 与顶层装配。架构门禁要求该文件不超过 500 行,并要求上述拆分文件 +持续存在。 + +HAP、LocalTest 与窄屏真机 Local → Remote → Local 往返均通过。真机 smoke 曾发现 +`@BuilderParam` slot 内直接构造 V2 组件会触发 `class constructor cannot called without +'new'`;现已改为由 `@Builder` 方法承接 slot,并复验进程在完整往返中持续存活。 +当前两个 target 分别为 1080 × 2444 真机和 466 × 466 模拟器,均不能提供宽屏三栏 +验收条件,因此 S1 的宽屏视觉复验仍记为待办。 + +**风险点(本阶段唯一)**:`@Builder` 值参数不响应式(§1.3.1)。拆分后凡是原先由父 builder 传入的标量,必须改为子 builder 内读状态——`wideMasterPaneCurrentWidth()` 就是这个坑的既有修复案例。 + +**验证**:完整验证回路 + **必须真机复验宽屏三栏与窄屏抽屉来源切换**。 + +--- + +### S2 · 消灭位置参数接线(2–3 天,零行为变更) + +**做什么**:12 个 `*Hooks` / `*Actions` 由 `class` + 位置构造改为 `interface` + 对象字面量。 + +```ts +// before —— 96 行实参,顺序错了编译期无感 +new AppRootPresentationActions(onA, onB, onC, /* … */) + +// after —— 字段名保护,新增回调只改两处 +const actions: AppRootPresentationActions = { + onA: () => { /* … */ }, + onB: () => { /* … */ }, + onC: () => { /* … */ } +}; +``` + +约 650 行接线降至约 250 行。可按 12 个类逐个 commit,每个独立可回滚。 + +**风险**:低。ArkTS 对象字面量要求有明确声明类型,`interface` 满足;改造过程中若某个 Hooks 含方法实现而非纯回调字段,保留为 class 但改为具名参数对象构造。 + +--- + +### S3 · 统一会话状态(3–5 天,**有行为风险**) + +**做什么** + +1. 抽出承载 §2.5 那 15 个共享字段的公共载体;`GeneralChatPageState` / `RemotePageState` 只保留各自特有字段; +2. 删除 `services/AppRootRouteState.ets`(88 行)——同时消掉硬违规 ① 的四分之一; +3. 收敛 `ConversationViewState.project` 的双源分支。 + +**前置 spike(0.5 天,必做)**:验证 ArkUI V2 的 `@Trace` 能否穿透 `@ObservedV2` 基类继承——本模块目前没有先例,不能假设。 + +- 若可以 → 用继承(`ConversationSessionState` 基类)。 +- 若不行 → **退化为组合**:两个 State 各持有一个 `ConversationCore` 字段,投影层只读 core。效果等价,只是访问路径多一层。 + +**Spike 结论(2026-08-06)**:采用组合方案。当前工程没有可证明 `@Trace` +跨 `@ObservedV2` 基类继承订阅关系的运行时先例,HAP 编译和 LocalTest 只能证明语法与 +状态行为,不能证明 UI 订阅穿透。`GeneralChatPageState` 与 `RemotePageState` 因此各自组合 +独立的 `ConversationCoreState`,组件和 `ConversationViewState` 直接读取 core。已通过窄屏 +真机 Local → Remote → Local 往返验证;宽屏真机仍需在折叠设备展开后复验。 + +**风险**:本方案中最高。但安全网充足——`ConversationStateUnit`(1057)+ `RemoteControllersUnit`(2177)直接覆盖这块。 + +**验证**:完整回路 + 真机走通四条路径:本地新建/继续会话、Remote 新建/继续会话、窄屏抽屉来源切换、宽屏来源切换。 + +--- + +### S4 · 拆解 God Facade(4–6 天,分批) + +**做什么**:按 S3 建立的特性边界,把 `AppRootRuntime` 切成 `ConversationController` / `RemoteConnectionController` / `SettingsController` / `FilePreviewController`,`AppRootRuntime` 退化为持有它们的组合根。 + +**实施结果(2026-08-07,已完成)**:已落地 `FilePreviewController`、 +`SettingsController`,并建立 `ConversationController` 的首批跨表面 composer/voice 状态边界; +对应旧方法已从 `AppRootRuntime` 删除,静态门禁禁止回流。现有连接实现也已从 +`RemoteConnectionViewModel` 更名为 `RemoteConnectionController`,根运行时的 21 个状态 getter +和 11 个连接状态转发已删除;路由、workspace/session 列表、polling/heartbeat 的 28 个 +owner 转发也已改为直接绑定。云账号凭据、持久化、云模型目录、权限设置与账号设备切换 +闭环也已迁入 `SettingsController`,包括原 91 行的 `selectCloudAccountDevice`。 +远程会话的发送、停止/重试、工具动作、时间线投影与 polling cursor 运行态已迁入 +`ConversationController`;Remote 新建会话的设备/workspace/模型选择、提交与路由流程也由其 +统一持有。本地会话的打开/新建/发送、草稿、归档与时间线投影同样已收口到该 owner。 +根运行时由 2608 行降至 372 行;纯依赖实例化和回调接线迁入 +`AppRootRuntimeComposition`,其抽象端口仍由根运行时实现,避免装配层反向拥有页面生命周期行为。 +HAP、完整 LocalTest 与窄屏真机 Local → Remote → Local 往返均通过。尚未完成 +宽屏复验,仍等待可用的展开设备。 + +顺序(每步独立 commit): + +1. 清理 45 个一行转发——调用点直接指向真正的 owner; +2. 拆 621 行字段初始化块(L140–761)为各 Controller 的构造; +3. 处理 `selectCloudAccountDevice`(91 行)等长方法; +4. 按官方 V2 形状收口:view 用 `@ComponentV2` + `@Local` 持有**所属特性的** ViewModel,而非同一个巨型对象。 + +**与 S5 的次序说明**:本阶段涉及的装配层(`AppRootPresentation` 及其拆出的 host)已经是 V2,`AppRoot.ets` 虽是 V1 但无任何状态装饰器,因此第 4 步不需要等 S5。S5 排在其后,是因为它的主体(`ConnectView`、`AppSidebar` 等叶子组件)与 Controller 拆分互不相干,放在结构稳定之后迁移,可以避免同一文件被两种性质的改动连续翻动。 + +目标:`AppRootRuntime` < 500 行。消除硬违规 ③。 + +**风险**:中。生命周期是重点——`aboutToAppear` / `onPageShow` / `onPageHide` / `aboutToDisappear` / `handleRootBack` 的调用顺序与轮询启停必须逐一保持。`LifecycleUnit`(748)+ `AppRootLifecycleUnit`(129)+ `AppRootRuntimeStartupUnit`(51)覆盖此处。 + +--- + +### S5 · V1 全量迁移到 V2(4–5 天,**逐文件有行为风险,已完成 2026-08-07**) + +**做什么**:把 §2.8 清单里的 15 个 V1 struct 全部迁到 `@ComponentV2`,之后 `pages/` 下不再存在 V1 装饰器。 + +**为什么值得单列一个阶段**(而不是像初版那样"顺手统一"): + +1. **官方推荐**。V2 是官方对新项目的推荐范式,官方 MVVM 示例也是 `@ComponentV2` + `@Local` 持有 ViewModel 实例的形式。范式统一后 §4 的目标结构与官方文档一一对应,不需要读代码的人在两套心智模型间切换。 +2. **消除一类难定位故障**。§2.8 已核查:目前**没有**任何 `@ObservedV2` 对象被 V1 装饰器持有。但只要 V1 struct 还在,后续任何一次改动都可能把状态对象传进 `@Prop`,得到"编译通过、界面不刷新"——与抽屉不刷新(§1.3.2)同型的故障,本模块已经为这类问题付出过一次排查成本。 +3. **观测边界更明确**。V2 的 `@Trace` 深度观测与 `@Monitor` 的新旧值回调,比 V1 的 `@Observed` / `@ObjectLink` 嵌套观测更容易推理,也更容易在 review 中判断对错。 + +**迁移映射表**(逐条替换,不是全局改名): + +| V1 | V2 | 语义差异——**必须逐字段确认,这是本阶段的主要风险**| +| --- | --- | --- | +| `@Component` | `@ComponentV2` | — | +| `@State`(53) | `@Local` | 基本等价,子组件自有状态 | +| `@Prop`(79) | `@Param` | **不等价**。V1 `@Prop` 是**深拷贝**,子组件可以本地改写;V2 `@Param` 是**按引用只读**,子组件不可赋值。凡是子组件确实在本地改写该字段的,需迁为 `@Param @Once`(仅初始同步、之后子组件自持)或 `@Local` + 显式初始化 | +| `@Link`(2) | `@Param` + `@Event` | **不等价**。V2 取消了双向绑定,须拆成"向下传值 + 向上回调"。仅 `CreateSessionSheet.ets` 的 `sessionTitle` / `instruction` 两处 | +| `@Watch`(4) | `@Monitor` | 回调签名不同,`@Monitor` 提供新旧值;`RemoteControlSettingsSheet` 1 处、`StreamingMarkdownContent` 3 处 | +| `@BuilderParam`(7) | 不变 | V2 同样支持,不属于迁移面 | + +**顺序**(每个文件独立 commit,从小到大以便先摸清坑): + +1. 先迁 5 个小文件(`DefaultAccountAvatar` 18、`ConversationSourceSwitcher` 40、`AppRoot` 48、`ChatStatusBar` 60、`ThinkingBlock` 67)——`AppRoot` 无任何状态装饰器,是纯粹的 `@Component` → `@ComponentV2` 改名,可作为第一个 commit 验证工具链; +2. 迁 `@Link` / `@Watch` 三个特殊文件(`CreateSessionSheet` 226、`StreamingMarkdownContent` 142、`RemoteControlSettingsSheet` 872)——语义变化集中在这里,单独处理便于 review; +3. 迁剩余中等文件(`FileReferenceCard` 85、`BitFunAccountLoginPage` 146、`MarkdownContent` 199、`SettingsSheet` 297、`ModelServiceSettingsPanel` 662); +4. 最后迁 `AppSidebar`(908)与 `ConnectView`(1344)——这两个占 V1 总量 44%,且是 S6 的拆分目标,**先迁后拆**:若先拆再迁,会在拆分过程中制造 V1/V2 交界,把两类风险叠在同一个 commit 里。 + +**风险**:中。集中在 `@Prop` → `@Param` 的 79 处——**不能批量替换**,每一处都要确认子组件是否本地改写。`StreamingMarkdownContent` 尤其要小心:它的 3 个 `@Prop` 全部带 `@Watch`,流式 Markdown 的增量渲染依赖这套回调时序。 + +**验证**:完整回路,且**每个 commit 都要真机验证该组件所在界面**。重点回归:连接流程(`ConnectView`)、侧栏与会话列表(`AppSidebar`)、Remote 控制设置(`RemoteControlSettingsSheet`)、流式回复渲染(`StreamingMarkdownContent`)、新建会话(`CreateSessionSheet`)。 + +**完成标志**:`ArchitectureUnit` 的 V1 已知清单清空,范式断言由"等于清单"翻为"必须为空";此后新增 V1 组件在 CI 直接失败。 + +**实际结果**:15 个 V1 页面组件全部迁移。逐字段审计结论是:只读父输入迁为 +`@Param`;需要用户编辑的值由子组件 `@Local` draft 持有,并通过显式事件上送; +`CreateSessionSheet` 的两个 `@Link` 拆为 `@Param` + `@Event`; +`StreamingMarkdownContent` 与 `RemoteControlSettingsSheet` 的监听迁为 `@Monitor`。 +本轮没有字段符合“只接收一次父级初值、之后完全由子组件持有”的语义,因此没有使用 +`@Param @Once`。HAP 编译同时验证 `@Param` 未被子组件赋值,架构门禁中的 V1 清单 +已经为空。HAP、LocalTest、窄屏启动与 Local → Remote → Local 往返均通过。 + +--- + +### S6 · 纯化组件层(3–4 天,纯视觉风险) + +**做什么** + +1. 侧栏和工具列表的重复 glyph 已分别收口到 `SidebarGlyphs.ets`、`ToolGlyphs.ets`; +2. 按视觉关注点拆出 `ConnectAccountDevicePage`(账号设备选择)、 + `ChatMessageContent`(图片/Markdown/文件卡片)两个 V2 子组件, + `AppSidebar` 从 908 行降至 700 行,`ConnectView` 从 1344 行降至 1055 行。 + `ToolStatusList` 的业务分组和交互状态仍保留在原 owner,避免纯视觉迁移改变工具动作时序。 +3. S1 同时完成根展示面的纯视觉拆分:Remote、窄屏路由、宽屏会话和 overlay 已由 + 四组 V2 host/surface 组件持有,`AppRootPresentation` 当前为 406 行。 +4. 第二批拆分已落地:`ConnectManualPairingOverlay` 持有手工配对表单, + `ToolInteractionPanels` 持有工具 JSON 编辑/批准和问答草稿,`ChatMessageChrome` + 持有用户气泡、重试提示和流式三点动画。对应主文件当前分别为 + `ConnectView` 695 行、`ToolStatusList` 1106 行、`ChatMessageBubble` 972 行;预算已写入 + `pnpm run harmony:architecture`,禁止展示职责回流。 + +**目标已按 §3.4 修正**:不包含"切断 `components → state`"——该 import 合法。**也不再包含装饰器统一**——S5 已完成,本阶段拆出的新组件天然是 V2。 + +**风险**:纯视觉回归。**每一步必须真机截图,窄屏 + 宽屏 × 浅色 + 深色四组**;所有颜色走 `Theme.ets` 语义 token,`pnpm run theme:color-audit:all` 必须干净。 + +**实际进度(2026-08-07,进行中)**:已完成窄屏浅色启动、侧栏展开、 +Local → Remote → Local 往返截图;新接入 HUAWEI MatePad Pro `WEB-W00` +(2880 × 1920),已安装本轮 HAP,并完成 Pad 浅色/深色下 Local、Remote Home 和连接 +设备面板截图,应用进程持续存活。宽屏合同不等于 Pad 合同:现有 +`ConversationLayoutPolicy` 同时读取零/一/两道纵向折痕,两道折痕的三折叠继续使用 +“左屏 master + 中/右两屏同一个 detail”,正文与关键热区选择不跨第二道折痕的最宽 +内容带;零/一/两道折痕、非对称三屏和非法折痕均有 LocalTest 覆盖。 + +三折叠完整展开及双屏/三屏动态切换仍需要真实两折痕设备验证,Pad 不能替代该项; +文件预览打开/关闭矩阵也尚未闭合,因此 S6 仍不能标记为完成。 + +--- + +### S7 · 关闭护栏(0.5 天) + +原 3 处 `services/` → `pages/` 反向依赖已在 S1/S3 的文件归属迁移中清零;当前 +`pnpm run harmony:architecture` 的 `serviceToPages`、`componentToViewmodel`、 +`viewmodelToComponents` 均为空,V1 清单也为空。门禁已从基线清单切换为永久空集, +并补齐了 `AGENTS.md` 与 `ArchitectureUnit` 的归属说明。 + +--- + +## 6. 每阶段固定验证回路 + +```bash +source scripts/ohos-env.sh + +# 1. 构建 +"$HVIGORW" --mode module -p product=default -p module=entry@default assembleHap --no-daemon + +# 2. 本地单元测试(6612 行 hypium 用例) +"$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon + +# 3. 颜色审计 +pnpm run theme:color-audit:all + +# 4. 真机验证(折叠设备 5ZU0226202001116) +hdc -t 5ZU0226202001116 shell snapshot_display -f /data/local/tmp/s.jpeg +hdc -t 5ZU0226202001116 file recv /data/local/tmp/s.jpeg ./s.jpeg +``` + +设备侧注意事项(已踩过的坑): + +- bundle 名是 **`com.example.bitfun_mobile`**,不是 `com.bitfun.mobile`; +- `hdc` 必须带 `-t `,否则报 `[Fail]ExecuteCommand need connect-key`(列出了两个 target); +- 外屏分辨率 1080×2444;点击用 `hdc -t shell uinput -T -c X Y`。 + +**真机验证的最低集合**(每阶段都要过):窄屏抽屉 Local ↔ Remote 来源切换、宽屏三栏、文件预览打开/关闭、深浅色各一轮。 + +--- + +## 7. 明确不做的事 + +- **不引入三层架构(products / features / commons)**。理由见 §1.2:单 entry 模块,收益为零、构建复杂度为正。 +- **不引入新的状态管理库或跨端抽象层**。问题是组织方式,不是工具。 +- **不重构 `services/general-chat/`(21 文件 / 3296 行)内部结构**。它自身分层是干净的,只需在 S7 切断对 `pages/` 的反向依赖。 +- **不追求行数目标本身**。S1 + S2 净减约 800 行是副产品;真正的收益是"改一处不用改三处"和"违规能被 CI 挡住"。 + +> 初版方案曾把"V1 → V2 全量迁移"列在本节。该判断已推翻——理由见 §5 的 S5 阶段,迁移已提升为独立阶段。 + +--- + +## 8. 遗留事项 + +- **窄屏"刷新"与"助手选择"入口缺失**(baseline `6c35485bb` 引入)。删除 `RemoteHomeView.ets` 统一窄屏 Remote 界面时,这两个入口一并移除,宽屏本来就没有。待定:是否补进共享侧栏的 `...` 菜单。此项与本重构无依赖关系,可独立处理。 +- **S6 组件纯化尚未完成**。优先继续拆分 `ToolStatusList`、`ChatMessageBubble` 与 + `ConnectView`,每次拆分保持动作 owner 和时序不变。 +- **视觉验证矩阵尚未闭合**。仍需补窄屏深色、文件预览打开/关闭,以及宽屏三栏的 + 深浅色截图;后者等待可用的展开折叠屏或平板 target。 diff --git a/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md b/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md index 35e2db0e7..a2296504c 100644 --- a/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md +++ b/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md @@ -6,7 +6,7 @@ Scope: `src/apps/mobile/harmonyos`,主要涉及双屏/三屏布局、会话来 ## 实施状态 -截至 2026-07-30: +截至 2026-08-07: ### 已实现 @@ -35,6 +35,7 @@ Scope: `src/apps/mobile/harmonyos`,主要涉及双屏/三屏布局、会话来 - 在同一设备的折叠单屏态(`1080 x 2444`)验证:页面保持原单屏头部和 Composer,可打开原侧边栏;点击 `Remote` 继续打开原“选择桌面设备”Sheet,系统返回可关闭 Sheet 并恢复本地 Home;本地历史会话的显示保持原样。 - 折叠单屏连接已有在线桌面后验证:远程 Home 保留原头部、菜单和会话列表;进入已有远程会话后保留原会话头部与 Composer;系统返回从远程会话回到远程 Home;打开原侧边栏并选择本地会话可恢复本地内容。全程未发送消息、运行命令或启动远程任务。 - 单屏根 `ChatHome` 的系统返回基线已核实:历史本地会话仍投影在根路由,侧边栏可见时也未接入根返回拦截,因此返回会退出 Ability。本次宽屏改动不改变该行为;是否优化应作为独立单屏导航问题处理。 +- 在 HUAWEI MatePad Pro(`WEB-W00`,`2880 x 1920`)安装最新 HAP,浅色与深色均验证本地/Remote 来源选择器、常驻 master、Remote 未连接占位和居中的连接设备面板;布局边界稳定,应用进程持续存活。Pad 验证只覆盖无折痕宽屏,不替代下述三折叠真机项。 ### 待验证 @@ -450,6 +451,7 @@ MasterDetail -> 双屏和三屏共同使用的 master-detail | 展开宽屏,本地会话 | 来源选择器保持“本地”,会话选中态正确,右侧显示当前会话 | | 展开宽屏,远程 Home | 来源选择器选中“Remote”,可一步切回本地,不显示全局侧边栏按钮 | | 展开宽屏,远程会话 | 来源选择器保持“Remote”,会话选中态正确,右侧显示当前会话,不显示全局侧边栏按钮 | +| 宽屏点击远程会话 | 左侧立即选中新会话;右侧立即进入该会话,慢加载时显示时间线骨架,完成后原位替换为历史消息 | | 三屏完整展开,本地会话 | 左屏显示本地 master,中间和右侧共同显示一个本地 detail | | 三屏完整展开,远程会话 | 左屏显示远程 master,中间和右侧共同显示一个远程 detail | | 三屏远程断开 | 左屏仍显示来源选择器,右侧两屏显示一个连续断开状态 | diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/FilePreviewTarget.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/model/FilePreviewTarget.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets index 4a918697d..3c22ccc44 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets @@ -1,9 +1,9 @@ import { AppRootPresentation } from './components/AppRootPresentation'; import { ArkUiAppRootHostAdapter } from './host/AppRootHostAdapter'; -import { AppRootRuntime } from './state/AppRootRuntime'; +import { AppRootRuntime } from './runtime/AppRootRuntime'; @Entry -@Component +@ComponentV2 struct AppRoot { private readonly hostAdapter: ArkUiAppRootHostAdapter = new ArkUiAppRootHostAdapter(); private readonly runtime: AppRootRuntime = new AppRootRuntime(this.hostAdapter); @@ -38,7 +38,7 @@ struct AppRoot { remoteCreateState: this.runtime.remoteCreateState, generalPageState: this.runtime.generalChatPageState, filePreviewState: this.runtime.filePreviewState, - deviceId: this.runtime.remoteConnectionViewModel.getDeviceId(), + deviceId: this.runtime.remoteConnectionController.getDeviceId(), actions: this.runtime.presentationActions }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets new file mode 100644 index 000000000..63fecd397 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets @@ -0,0 +1,152 @@ +import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ConversationIntent } from './ConversationIntent'; +import { AppRoute, ConversationSource } from '../navigation/AppRouteContract'; + +export interface AppRootPresentationActions { + readonly onNavigationBack: (route: AppRoute) => boolean; + readonly onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void; + readonly onCloseSidebar: () => void; + readonly onWideConversationSource: (source: ConversationSource) => void; + readonly onCompactConversationSource: (source: ConversationSource) => void; + readonly onCompactLayoutEntered: () => void; + readonly onLayoutModeChanged: (wideLayout: boolean) => void; + readonly onRemoteHome: RemoteHomePresentationActions; + readonly onRemoteCreate: RemoteCreatePresentationActions; + readonly onSidebar: SidebarPresentationActions; + readonly onSettings: SettingsPresentationActions; + readonly onConnect: ConnectPresentationActions; + readonly onFilePreview: FilePreviewPresentationActions; + readonly generalStatus: () => string; +} + +export interface FilePreviewPresentationActions { + readonly close: () => void; + readonly refresh: () => void; + readonly download: (path: string) => void; + readonly openLink: (reference: string, label: string) => void; +} + +export interface RemoteCreatePresentationActions { + readonly back: () => void; + readonly toggleDevices: () => void; + readonly toggleWorkspaces: () => void; + readonly selectDevice: (device: CloudAccountDevice) => void; + readonly selectWorkspace: (path: string) => void; + readonly draftChanged: (value: string) => void; + readonly voiceInput: () => void; + readonly selectModel: (modelId: string) => void; + readonly send: () => void; +} + +export interface RemoteHomePresentationActions { + readonly openSidebar: () => void; + readonly connectWorkspace: () => void; + readonly addConnection: () => void; + readonly openSettings: () => void; + readonly refresh: () => void; + readonly showWorkspaces: () => void; + readonly showAssistants: () => void; + readonly selectWorkspace: (path: string) => void; + readonly selectAssistant: (path: string) => void; + readonly cancelWorkspace: () => void; + readonly cancelAssistant: () => void; + readonly queryChanged: (query: string) => void; + readonly search: () => void; + readonly loadMore: () => void; + readonly reconnect: () => void; + readonly disconnect: () => void; + readonly clearPairing: () => void; + readonly create: (agentType: string) => void; + readonly createInPlace: (agentType: string) => void; + readonly createAssistant: () => void; + readonly createInWorkspace: (path: string, agentType: string) => void; + readonly createInWorkspaceInPlace: (path: string, agentType: string) => void; + readonly openSession: (session: RemoteSession) => void; + readonly openSessionInPlace: (session: RemoteSession) => void; + readonly deleteSession: (session: RemoteSession) => void; +} + +export interface SidebarPresentationActions { + readonly close: () => void; + readonly newChat: () => void; + readonly enterCode: () => void; + readonly settings: () => void; + readonly openAccount: () => void; + readonly openSession: (session: RemoteSession) => void; + readonly archive: (session: RemoteSession, archived: boolean) => void; + readonly exportSession: (session: RemoteSession) => void; + readonly deleteSession: (session: RemoteSession) => void; +} + +export interface SettingsPresentationActions { + readonly close: () => void; + readonly addConnection: () => void; + readonly disconnect: () => void; + readonly reconnect: () => void; + readonly openAccount: () => void; + readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise; + readonly cloudSync: () => Promise; + readonly cloudLogout: () => Promise; + readonly cloudListDevices: () => Promise; + readonly getPermissionMode: () => Promise; + readonly setPermissionMode: (mode: RemotePermissionMode) => Promise; + readonly testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; + readonly saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; +} + +export interface ConnectPresentationActions { + readonly back: () => void; + readonly connect: (password?: string) => void; + readonly clearPairing: () => void; + readonly urlChanged: (url: string) => void; + readonly userChanged: (user: string) => void; + readonly detected: (url: string) => boolean; + readonly inputVisible: (visible: boolean) => void; + readonly paste: () => void; + readonly scan: () => void; + readonly cloudListDevices: () => Promise; + readonly cloudSelectDevice: (device: CloudAccountDevice) => Promise; +} + +export function emptyAppRootPresentationActions(): AppRootPresentationActions { + return { + onNavigationBack: () => false, + onConversationIntent: () => {}, + onCloseSidebar: () => {}, + onWideConversationSource: () => {}, + onCompactConversationSource: () => {}, + onCompactLayoutEntered: () => {}, + onLayoutModeChanged: () => {}, + onRemoteHome: { + openSidebar: () => {}, connectWorkspace: () => {}, addConnection: () => {}, openSettings: () => {}, + refresh: () => {}, showWorkspaces: () => {}, showAssistants: () => {}, selectWorkspace: () => {}, + selectAssistant: () => {}, cancelWorkspace: () => {}, cancelAssistant: () => {}, queryChanged: () => {}, + search: () => {}, loadMore: () => {}, reconnect: () => {}, disconnect: () => {}, clearPairing: () => {}, + create: () => {}, createInPlace: () => {}, createAssistant: () => {}, createInWorkspace: () => {}, + createInWorkspaceInPlace: () => {}, openSession: () => {}, openSessionInPlace: () => {}, deleteSession: () => {} + }, + onRemoteCreate: { + back: () => {}, toggleDevices: () => {}, toggleWorkspaces: () => {}, selectDevice: () => {}, + selectWorkspace: () => {}, draftChanged: () => {}, voiceInput: () => {}, selectModel: () => {}, send: () => {} + }, + onSidebar: { + close: () => {}, newChat: () => {}, enterCode: () => {}, settings: () => {}, openAccount: () => {}, + openSession: () => {}, archive: () => {}, exportSession: () => {}, deleteSession: () => {} + }, + onSettings: { + close: () => {}, addConnection: () => {}, disconnect: () => {}, reconnect: () => {}, openAccount: () => {}, + cloudLogin: async () => '', cloudSync: async () => '', cloudLogout: async () => {}, + cloudListDevices: async () => [], getPermissionMode: async () => 'ask', + setPermissionMode: async (mode: RemotePermissionMode) => mode, + testGeneral: async () => '', saveGeneral: async () => '' + }, + onConnect: { + back: () => {}, connect: () => {}, clearPairing: () => {}, urlChanged: () => {}, userChanged: () => {}, + detected: () => false, inputVisible: () => {}, paste: () => {}, scan: () => {}, + cloudListDevices: async () => [], cloudSelectDevice: async () => {} + }, + onFilePreview: { close: () => {}, refresh: () => {}, download: () => {}, openLink: () => {} }, + generalStatus: () => '' + }; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets similarity index 94% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets index 6531352ee..76dc0a66e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets @@ -1,5 +1,5 @@ -import { ConversationUiQuestionAnswer } from './ConversationUiModels'; -import { FilePreviewRequest } from '../state/FilePreviewTarget'; +import { ConversationUiQuestionAnswer } from '../components/ConversationUiModels'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; export enum ConversationIntentType { OpenSidebar = 'open_sidebar', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets similarity index 67% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets index 0c2c8bbf7..a07455141 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets @@ -1,10 +1,10 @@ import { RemoteQuestionAnswerPayload, RemoteSession } from '../../model/RemoteModels'; import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; -import { ConversationIntent, ConversationIntentType } from '../components/ConversationIntent'; +import { ConversationIntent, ConversationIntentType } from './ConversationIntent'; import { toRemoteQuestionAnswer } from '../components/ConversationUiModels'; -import { FilePreviewRequest } from './FilePreviewTarget'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; -export class ConversationIntentDispatcherHooks { +export interface ConversationIntentDispatcherHooks { readonly openSidebar: () => void; readonly back: () => void; readonly newRemoteSession: () => void; @@ -35,33 +35,6 @@ export class ConversationIntentDispatcherHooks { readonly send: () => Promise; readonly voiceInput: () => Promise; readonly inputChanged: (route: AppRoute, value: string) => void; - - constructor( - openSidebar: () => void, back: () => void, newRemoteSession: () => void, newGeneralSession: () => void, - activeGeneralSession: () => RemoteSession, activeGeneralSessionId: () => string, - isGeneralBusy: () => boolean, isPinned: (id: string) => boolean, - pin: (session: RemoteSession, pinned: boolean, busy: boolean) => Promise, - archive: (session: RemoteSession) => Promise, deleteSession: (session: RemoteSession) => Promise, - showToast: (text: string) => void, uploadedFileCount: () => number, - stop: () => Promise, loadOlder: () => Promise, approve: (id: string, input?: Object) => Promise, - reject: (id: string) => Promise, cancel: (id: string) => Promise, - answer: (id: string, answers: RemoteQuestionAnswerPayload) => Promise, rename: (title: string) => Promise, - copy: (text: string) => Promise, retry: (text: string) => Promise, selectModel: (id: string) => Promise, - pickImages: () => Promise, removeImage: (id: string) => void, - openFilePreview: (route: AppRoute, request: FilePreviewRequest) => void, downloadFile: (path: string) => void, - send: () => Promise, voiceInput: () => Promise, inputChanged: (route: AppRoute, value: string) => void - ) { - this.openSidebar = openSidebar; this.back = back; this.newRemoteSession = newRemoteSession; - this.newGeneralSession = newGeneralSession; this.activeGeneralSession = activeGeneralSession; - this.activeGeneralSessionId = activeGeneralSessionId; this.isGeneralBusy = isGeneralBusy; - this.isPinned = isPinned; this.pin = pin; this.archive = archive; this.delete = deleteSession; - this.showToast = showToast; this.uploadedFileCount = uploadedFileCount; this.stop = stop; - this.loadOlder = loadOlder; this.approve = approve; this.reject = reject; this.cancel = cancel; - this.answer = answer; this.rename = rename; this.copy = copy; this.retry = retry; - this.selectModel = selectModel; this.pickImages = pickImages; this.removeImage = removeImage; - this.openFilePreview = openFilePreview; this.downloadFile = downloadFile; this.send = send; - this.voiceInput = voiceInput; this.inputChanged = inputChanged; - } } export class ConversationIntentDispatcher { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets new file mode 100644 index 000000000..734b9f7ea --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets @@ -0,0 +1,185 @@ +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { AppRouteContract, ConversationSource } from '../navigation/AppRouteContract'; +import { AppShellState } from '../state/AppShellState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { AppSidebar } from './AppSidebar'; +import { ConnectView } from './ConnectView'; +import { RemoteControlSettingsSheet } from './RemoteControlSettingsSheet'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; +import { SettingsSheet } from './SettingsSheet'; + +@ComponentV2 +export struct AppSidebarSurface { + @Param shellState: AppShellState = new AppShellState(); + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Event onOpenRemoteViewSettings: () => void = () => {}; + + build() { + AppSidebar({ + sessions: this.source() === ConversationSource.Remote ? [] : this.generalPageState.recentSessions(), + pinnedSessionId: this.generalPageState.pinnedSessionId(), + selectedSessionId: this.source() === ConversationSource.Remote ? '' : + (AppRouteContract.isGeneralComposerRoute(this.shellState.activeRoute) ? + this.generalPageState.conversation.activeSession.sessionId : ''), + connectionState: this.remotePageState.connectionState, + accountUserId: this.remotePageState.accountUserId, + activeSection: this.source() === ConversationSource.Remote ? 'remote' : 'chat', + showConversationSourceSwitcher: true, + showViewSettingsButton: this.source() === ConversationSource.Remote, + showCustomContent: this.source() === ConversationSource.Remote, + conversationSource: this.source(), + contentSlot: () => { + this.RemoteContent() + }, + onClose: this.actions.onSidebar.close, + onNewChat: () => this.newChat(), + onEnterCode: this.actions.onSidebar.enterCode, + onConversationSource: this.actions.onCompactConversationSource, + onOpenViewSettings: this.onOpenRemoteViewSettings, + onSearchQueryChange: (query: string) => { + if (this.source() === ConversationSource.Remote) this.actions.onRemoteHome.queryChanged(query); + }, + onOpenSettings: () => this.openSettings(), + onOpenAccount: this.actions.onSidebar.openAccount, + onOpenSession: this.actions.onSidebar.openSession, + onArchiveSession: this.actions.onSidebar.archive, + onExportSession: this.actions.onSidebar.exportSession, + onDeleteSession: this.actions.onSidebar.deleteSession + }) + } + + @Builder + private RemoteContent() { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Master, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + showSelectedSession: true, + compact: true + }) + } + + private source(): ConversationSource { + return AppRouteContract.conversationSource(this.shellState.activeRoute); + } + + private newChat(): void { + if (this.source() === ConversationSource.Remote) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.createAssistant(); + } else { + this.actions.onSidebar.newChat(); + } + } + + private openSettings(): void { + if (this.source() === ConversationSource.Remote) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.openSettings(); + } else { + this.actions.onSidebar.settings(); + } + } +} + +@ComponentV2 +export struct AppSettingsSurface { + @Param shellState: AppShellState = new AppShellState(); + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param deviceId: string = ''; + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + + build() { + if (this.shellState.settingsMode === 'remote' || this.shellState.settingsMode === 'account') { + RemoteControlSettingsSheet({ + desktopName: this.remotePageState.desktopName, + desktopId: this.remotePageState.desktopId, + userId: this.remotePageState.userId, + accountUsername: this.remotePageState.accountUsername, + accountUserId: this.remotePageState.accountUserId, + deviceId: this.deviceId, + controlTargetType: this.remotePageState.controlTargetType, + controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, + connectionState: this.remotePageState.connectionState, + statusText: this.remotePageState.conversation.statusText, + isBusy: this.remotePageState.conversation.isBusy, + onClose: this.actions.onSettings.close, + onOpenAccount: this.actions.onSettings.openAccount, + onAddConnection: this.actions.onSettings.addConnection, + cloudLogin: this.actions.onSettings.cloudLogin, + cloudSync: this.actions.onSettings.cloudSync, + cloudLogout: this.actions.onSettings.cloudLogout, + cloudListDevices: this.actions.onSettings.cloudListDevices, + getPermissionMode: this.actions.onSettings.getPermissionMode, + setPermissionMode: this.actions.onSettings.setPermissionMode, + openAccountOnAppear: this.shellState.settingsMode === 'account', + onDisconnect: this.actions.onSettings.disconnect, + onReconnect: this.actions.onSettings.reconnect + }) + } else { + SettingsSheet({ + generalChatApiUrl: this.generalPageState.apiUrl, + generalChatModelName: this.generalPageState.modelName, + hasGeneralChatApiKey: this.generalPageState.hasApiKey, + generalChatModelCatalog: this.generalPageState.conversation.modelCatalog, + selectedGeneralChatModelId: this.generalPageState.conversation.selectedModelId, + accountUsername: this.remotePageState.accountUsername, + authenticatedUserId: this.remotePageState.accountUserId, + deviceId: this.deviceId, + onOpenAccount: this.actions.onSettings.openAccount, + onTestGeneralChatConfig: this.actions.onSettings.testGeneral, + onSaveGeneralChatConfig: this.actions.onSettings.saveGeneral, + onClose: this.actions.onSettings.close + }) + } + } +} + +@ComponentV2 +export struct AppConnectSurface { + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param deviceId: string = ''; + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + + build() { + ConnectView({ + remoteUrl: this.remotePageState.remoteUrl, + userId: this.remotePageState.userId, + statusText: this.remotePageState.conversation.statusText, + connectionState: this.remotePageState.connectionState, + connectionFailureKind: this.remotePageState.connectionFailureKind, + isBusy: this.remotePageState.conversation.isBusy, + isConnected: this.remotePageState.connectionState === 'connected', + desktopName: this.remotePageState.desktopName, + deviceId: this.deviceId, + accountUserId: this.remotePageState.accountUserId, + controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, + requiresAccountAuth: this.remotePageState.requiresAccountAuth, + accountUsername: this.remotePageState.accountUsername, + startWithScanner: true, + onBack: this.actions.onConnect.back, + onConnect: this.actions.onConnect.connect, + onRemoteUrlChange: this.actions.onConnect.urlChanged, + onUserIdChange: this.actions.onConnect.userChanged, + onRemoteUrlDetected: this.actions.onConnect.detected, + onRemoteUrlInputVisibleChange: this.actions.onConnect.inputVisible, + cloudListDevices: this.actions.onConnect.cloudListDevices, + cloudSelectDevice: this.actions.onConnect.cloudSelectDevice + }) + .width('100%') + .height('100%') + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets index c38cd9de2..cd5564c45 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets @@ -1,47 +1,42 @@ import display from '@ohos.display'; import deviceInfo from '@ohos.deviceInfo'; import mediaQuery from '@ohos.mediaquery'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; -import { CloudAccountDevice } from '../../services/CloudAccountClient'; -import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteUiState } from '../../services/RemoteUiState'; import { AppShell } from './AppShell'; -import { AppSidebar } from './AppSidebar'; -import { ConnectView } from './ConnectView'; -import { ConversationIntent } from './ConversationIntent'; -import { ComposerPresentation } from './ComposerBar'; -import { ConversationViewSettings } from './ConversationViewSettings'; -import { ConversationViewHost } from './ConversationViewHost'; -import { toConversationUiModelCatalog } from './ConversationUiModels'; import { FilePreviewSurface } from './FilePreviewSurface'; -import { GeneralChatHeader } from './GeneralChatHeader'; -import { RemoteControlSettingsSheet } from './RemoteControlSettingsSheet'; -import { RemoteCreateSessionView } from './RemoteCreateSessionView'; -import { RemoteSessionList } from './RemoteSessionList'; -import { RemoteSessionLoadingView } from './RemoteSessionLoadingView'; -import { SidebarToggleButton } from './SidebarToggleButton'; -import { SessionActionPresentation } from './SessionActionSurface'; -import { SettingsSheet } from './SettingsSheet'; -import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED } from './Theme'; -import { AppRoute, AppRouteContract, ConversationSource } from '../navigation/AppRouteContract'; +import { PAGE_BG } from './Theme'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; import { AppShellState } from '../state/AppShellState'; import { ConversationLayoutCrease, ConversationLayoutPolicy -} from '../state/ConversationLayoutPolicy'; +} from '../policy/ConversationLayoutPolicy'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemotePageState } from '../state/RemotePageState'; import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; -import { ConversationViewState } from '../state/ConversationViewState'; -import { FilePreviewPhase, FilePreviewState } from '../state/FilePreviewState'; +import { FilePreviewState } from '../state/FilePreviewState'; import { FilePreviewLayout, FilePreviewPlacement, FilePreviewPlacementPolicy -} from '../state/FilePreviewPlacementPolicy'; - -const WIDE_DETAIL_CONTENT_MAX_WIDTH: number = 920; +} from '../policy/FilePreviewPlacementPolicy'; +import { WideLayoutGeometry } from '../layout/WideLayoutGeometry'; +import { ConversationRouteSurface } from './ConversationRouteSurface'; +import { WideConversationHost } from './WideConversationHost'; +import { + AppConnectSurface, + AppSettingsSurface, + AppSidebarSurface +} from './AppRootOverlaySurfaces'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; function safeFoldStatus(): display.FoldStatus { try { @@ -59,216 +54,6 @@ function safeDeviceType(): string { } } -export class AppRootPresentationActions { - readonly onNavigationBack: (route: AppRoute) => boolean; - readonly onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void; - readonly onCloseSidebar: () => void; - readonly onWideConversationSource: (source: ConversationSource) => void; - readonly onCompactConversationSource: (source: ConversationSource) => void; - readonly onCompactLayoutEntered: () => void; - readonly onRemoteHome: RemoteHomePresentationActions; - readonly onRemoteCreate: RemoteCreatePresentationActions; - readonly onSidebar: SidebarPresentationActions; - readonly onSettings: SettingsPresentationActions; - readonly onConnect: ConnectPresentationActions; - readonly onFilePreview: FilePreviewPresentationActions; - readonly generalStatus: () => string; - - constructor( - onNavigationBack: (route: AppRoute) => boolean, - onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void, - onCloseSidebar: () => void, - onWideConversationSource: (source: ConversationSource) => void, - onCompactConversationSource: (source: ConversationSource) => void, - onCompactLayoutEntered: () => void, - onRemoteHome: RemoteHomePresentationActions, - onRemoteCreate: RemoteCreatePresentationActions, - onSidebar: SidebarPresentationActions, - onSettings: SettingsPresentationActions, - onConnect: ConnectPresentationActions, - onFilePreview: FilePreviewPresentationActions, - generalStatus: () => string - ) { - this.onNavigationBack = onNavigationBack; - this.onConversationIntent = onConversationIntent; - this.onCloseSidebar = onCloseSidebar; - this.onWideConversationSource = onWideConversationSource; - this.onCompactConversationSource = onCompactConversationSource; - this.onCompactLayoutEntered = onCompactLayoutEntered; - this.onRemoteHome = onRemoteHome; - this.onRemoteCreate = onRemoteCreate; - this.onSidebar = onSidebar; - this.onSettings = onSettings; - this.onConnect = onConnect; - this.onFilePreview = onFilePreview; - this.generalStatus = generalStatus; - } -} - -export class FilePreviewPresentationActions { - readonly close: () => void; - readonly refresh: () => void; - readonly download: (path: string) => void; - readonly openLink: (reference: string, label: string) => void; - - constructor( - close: () => void, - refresh: () => void, - download: (path: string) => void, - openLink: (reference: string, label: string) => void - ) { - this.close = close; - this.refresh = refresh; - this.download = download; - this.openLink = openLink; - } -} - -export class RemoteCreatePresentationActions { - readonly back: () => void; - readonly toggleDevices: () => void; - readonly toggleWorkspaces: () => void; - readonly selectDevice: (device: CloudAccountDevice) => void; - readonly selectWorkspace: (path: string) => void; - readonly draftChanged: (value: string) => void; - readonly voiceInput: () => void; - readonly selectModel: (modelId: string) => void; - readonly send: () => void; - - constructor( - back: () => void, - toggleDevices: () => void, - toggleWorkspaces: () => void, - selectDevice: (device: CloudAccountDevice) => void, - selectWorkspace: (path: string) => void, - draftChanged: (value: string) => void, - voiceInput: () => void, - selectModel: (modelId: string) => void, - send: () => void - ) { - this.back = back; - this.toggleDevices = toggleDevices; - this.toggleWorkspaces = toggleWorkspaces; - this.selectDevice = selectDevice; - this.selectWorkspace = selectWorkspace; - this.draftChanged = draftChanged; - this.voiceInput = voiceInput; - this.selectModel = selectModel; - this.send = send; - } -} - -export class RemoteHomePresentationActions { - readonly openSidebar: () => void; readonly connectWorkspace: () => void; - readonly addConnection: () => void; readonly openSettings: () => void; - readonly refresh: () => void; readonly showWorkspaces: () => void; readonly showAssistants: () => void; - readonly selectWorkspace: (path: string) => void; readonly selectAssistant: (path: string) => void; - readonly cancelWorkspace: () => void; readonly cancelAssistant: () => void; - readonly queryChanged: (query: string) => void; readonly search: () => void; readonly loadMore: () => void; - readonly reconnect: () => void; readonly disconnect: () => void; readonly clearPairing: () => void; - readonly create: (agentType: string) => void; readonly createInPlace: (agentType: string) => void; - readonly createAssistant: () => void; - readonly createInWorkspace: (path: string, agentType: string) => void; - readonly createInWorkspaceInPlace: (path: string, agentType: string) => void; - readonly openSession: (session: RemoteSession) => void; - readonly openSessionInPlace: (session: RemoteSession) => void; - readonly deleteSession: (session: RemoteSession) => void; - - constructor( - openSidebar: () => void, connectWorkspace: () => void, addConnection: () => void, openSettings: () => void, - refresh: () => void, showWorkspaces: () => void, showAssistants: () => void, - selectWorkspace: (path: string) => void, selectAssistant: (path: string) => void, - cancelWorkspace: () => void, cancelAssistant: () => void, queryChanged: (query: string) => void, - search: () => void, loadMore: () => void, reconnect: () => void, disconnect: () => void, - clearPairing: () => void, create: (agentType: string) => void, createInPlace: (agentType: string) => void, - createAssistant: () => void, - createInWorkspace: (path: string, agentType: string) => void, - createInWorkspaceInPlace: (path: string, agentType: string) => void, openSession: (session: RemoteSession) => void, - openSessionInPlace: (session: RemoteSession) => void, - deleteSession: (session: RemoteSession) => void - ) { - this.openSidebar = openSidebar; this.connectWorkspace = connectWorkspace; this.addConnection = addConnection; - this.openSettings = openSettings; this.refresh = refresh; this.showWorkspaces = showWorkspaces; - this.showAssistants = showAssistants; this.selectWorkspace = selectWorkspace; this.selectAssistant = selectAssistant; - this.cancelWorkspace = cancelWorkspace; this.cancelAssistant = cancelAssistant; this.queryChanged = queryChanged; - this.search = search; this.loadMore = loadMore; this.reconnect = reconnect; this.disconnect = disconnect; - this.clearPairing = clearPairing; this.create = create; this.createInPlace = createInPlace; - this.createAssistant = createAssistant; this.createInWorkspace = createInWorkspace; - this.createInWorkspaceInPlace = createInWorkspaceInPlace; this.openSession = openSession; - this.openSessionInPlace = openSessionInPlace; this.deleteSession = deleteSession; - } -} - -export class SidebarPresentationActions { - readonly close: () => void; readonly newChat: () => void; readonly enterCode: () => void; - readonly settings: () => void; readonly openAccount: () => void; - readonly openSession: (session: RemoteSession) => void; - readonly archive: (session: RemoteSession, archived: boolean) => void; - readonly exportSession: (session: RemoteSession) => void; readonly deleteSession: (session: RemoteSession) => void; - constructor( - close: () => void, newChat: () => void, enterCode: () => void, settings: () => void, openAccount: () => void, - openSession: (session: RemoteSession) => void, archive: (session: RemoteSession, archived: boolean) => void, - exportSession: (session: RemoteSession) => void, deleteSession: (session: RemoteSession) => void - ) { - this.close = close; this.newChat = newChat; this.enterCode = enterCode; this.settings = settings; - this.openAccount = openAccount; - this.openSession = openSession; this.archive = archive; this.exportSession = exportSession; this.deleteSession = deleteSession; - } -} - -export class SettingsPresentationActions { - readonly close: () => void; readonly addConnection: () => void; readonly disconnect: () => void; - readonly reconnect: () => void; - readonly openAccount: () => void; - readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise; - readonly cloudSync: () => Promise; - readonly cloudLogout: () => Promise; - readonly cloudListDevices: () => Promise; - readonly getPermissionMode: () => Promise; - readonly setPermissionMode: (mode: RemotePermissionMode) => Promise; - readonly testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; - readonly saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; - constructor( - close: () => void, addConnection: () => void, disconnect: () => void, reconnect: () => void, - openAccount: () => void, - cloudLogin: (relayUrl: string, username: string, password: string) => Promise, - cloudSync: () => Promise, cloudLogout: () => Promise, - cloudListDevices: () => Promise, - getPermissionMode: () => Promise, - setPermissionMode: (mode: RemotePermissionMode) => Promise, - testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise, - saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise - ) { - this.close = close; this.addConnection = addConnection; this.disconnect = disconnect; - this.reconnect = reconnect; this.openAccount = openAccount; this.cloudLogin = cloudLogin; this.cloudSync = cloudSync; - this.cloudLogout = cloudLogout; this.cloudListDevices = cloudListDevices; - this.getPermissionMode = getPermissionMode; this.setPermissionMode = setPermissionMode; - this.testGeneral = testGeneral; - this.saveGeneral = saveGeneral; - } -} - -export class ConnectPresentationActions { - readonly back: () => void; readonly connect: (password?: string) => void; readonly clearPairing: () => void; - readonly urlChanged: (url: string) => void; readonly userChanged: (user: string) => void; - readonly detected: (url: string) => boolean; readonly inputVisible: (visible: boolean) => void; - readonly paste: () => void; readonly scan: () => void; - readonly cloudListDevices: () => Promise; - readonly cloudSelectDevice: (device: CloudAccountDevice) => Promise; - constructor( - back: () => void, connect: (password?: string) => void, clearPairing: () => void, - urlChanged: (url: string) => void, userChanged: (user: string) => void, - detected: (url: string) => boolean, inputVisible: (visible: boolean) => void, - paste: () => void, scan: () => void, cloudListDevices: () => Promise, - cloudSelectDevice: (device: CloudAccountDevice) => Promise - ) { - this.back = back; this.connect = connect; this.clearPairing = clearPairing; - this.urlChanged = urlChanged; this.userChanged = userChanged; this.detected = detected; - this.inputVisible = inputVisible; this.paste = paste; this.scan = scan; - this.cloudListDevices = cloudListDevices; this.cloudSelectDevice = cloudSelectDevice; - } -} - @ComponentV2 export struct AppRootPresentation { @Param shellState: AppShellState = new AppShellState(); @@ -291,14 +76,8 @@ export struct AppRootPresentation { @Local wideMasterPaneCollapsed: boolean = false; @Local wideMasterPaneMotionActive: boolean = false; @Local restoreCollapsedMasterAfterPreview: boolean = false; - @Local remoteWideSortMode: string = 'project'; - @Local remoteWorkspaceFilter: string = ''; - @Local remoteAgentFilter: string = ''; - @Local remoteStatusFilter: string = ''; @Local showRemoteViewSettings: boolean = false; - @Local showRemoteWorkspaceMetadata: boolean = false; - @Local showRemoteUpdatedMetadata: boolean = false; - @Local showRemoteStatusMetadata: boolean = false; + @Local remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); private readonly deviceType: string = safeDeviceType(); private verticalCreases: ConversationLayoutCrease[] = []; private wideQueryListener?: mediaQuery.MediaQueryListener; @@ -312,19 +91,7 @@ export struct AppRootPresentation { this.wideLayoutMatched = result.matches; this.refreshWideGeometry(); }; - @Param actions: AppRootPresentationActions = new AppRootPresentationActions( - () => false, () => {}, () => {}, () => {}, () => {}, () => {}, - new RemoteHomePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, - () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, - () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), - new RemoteCreatePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), - new SidebarPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), - new SettingsPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, async (_relayUrl: string, _username: string, _password: string): Promise => '', async (): Promise => '', async (): Promise => {}, async (): Promise => [], async (): Promise => 'ask', async (mode: RemotePermissionMode): Promise => mode, async (_url: string, _key: string, _model: string, _clear: boolean): Promise => '', async (_url: string, _key: string, _model: string, _clear: boolean): Promise => ''), - new ConnectPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => false, () => {}, () => {}, - () => {}, async (): Promise => [], async (_device: CloudAccountDevice): Promise => {}), - new FilePreviewPresentationActions(() => {}, () => {}, () => {}, () => {}), - () => '' - ); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); aboutToAppear(): void { this.bindResponsiveQueries(); @@ -378,423 +145,58 @@ export struct AppRootPresentation { @Builder RouteContent(route: AppRoute) { - if (this.isGeneralWideRoute(route) && this.isWideLayout()) { - this.WideGeneralChatContent(route) - } else if (this.showsWideRemoteConversation(route) && - this.filePreviewPlacement() === FilePreviewPlacement.WideFocusSplit) { - this.WideRemotePreviewFocusContent() - } else if (this.showsWideRemoteConversation(route)) { - this.WideRemoteChatContent() - } else if (route === AppRoute.RemoteHome && this.isWideLayout()) { - this.WideRemoteHomeContent() - } else if (route === AppRoute.RemoteCreate && this.isWideLayout()) { - this.WideRemoteCreateContent() + if (this.isWideLayout() && this.isConversationRoute(route)) { + WideConversationHost({ + route, + shellState: this.shellState, + remotePageState: this.remotePageState, + remoteCreateState: this.remoteCreateState, + generalPageState: this.generalPageState, + filePreviewState: this.filePreviewState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + filePreviewLayout: this.filePreviewLayout(), + wideMasterPaneWidth: this.wideMasterPaneWidth, + wideMasterDetailGap: this.wideMasterDetailGap, + wideDetailContentOffset: this.wideDetailContentOffset, + wideDetailContentWidth: this.wideDetailContentWidth, + wideCollapsedDetailContentOffset: this.wideCollapsedDetailContentOffset, + wideCollapsedDetailContentWidth: this.wideCollapsedDetailContentWidth, + wideMasterPaneCollapsed: this.wideMasterPaneCollapsed, + wideMasterPaneMotionActive: this.wideMasterPaneMotionActive, + onCollapseMasterPane: () => this.collapseWideMasterPane(), + onRestoreMasterPane: () => this.restoreWideMasterPane(), + onOpenRemoteViewSettings: () => { this.showRemoteViewSettings = true; } + }) } else { - this.RouteSurfaceContent(route, true, route !== AppRoute.ChatHome) - } - } - - @Builder - RouteSurfaceContent( - route: AppRoute, - showSidebarButton: boolean, - showBackButton: boolean, - showSidebarRestoreButton: boolean = false, - useWidePresentation: boolean = false - ) { - Column() { - if (route === AppRoute.RemoteHome) { - this.CompactRemoteHomeContent() - } else if (route === AppRoute.RemoteCreate) { - RemoteCreateSessionView({ - state: this.remoteCreateState, - presentation: useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Create, - isVoiceListening: this.remoteCreateState.isVoiceListening, - modelCatalog: toConversationUiModelCatalog(this.remotePageState.modelCatalog), - selectedModelId: this.remoteCreateState.selectedModelId, - showSidebarRestoreButton: showSidebarRestoreButton, - onRestoreSidebar: () => { - this.restoreWideMasterPane(); - }, - onBack: this.actions.onRemoteCreate.back, - onToggleDeviceMenu: this.actions.onRemoteCreate.toggleDevices, - onToggleWorkspaceMenu: this.actions.onRemoteCreate.toggleWorkspaces, - onSelectDevice: this.actions.onRemoteCreate.selectDevice, - onSelectWorkspace: (workspace) => this.actions.onRemoteCreate.selectWorkspace(workspace?.path || ''), - onDraftChange: this.actions.onRemoteCreate.draftChanged, - onVoiceInput: this.actions.onRemoteCreate.voiceInput, - onSelectModel: this.actions.onRemoteCreate.selectModel, - onSend: this.actions.onRemoteCreate.send - }) - } else { - ConversationViewHost({ - viewState: ConversationViewState.project(route, this.remotePageState, this.generalPageState, - this.actions.generalStatus()), - activeFilePreviewPath: route === AppRoute.RemoteChat && this.filePreviewState.visible ? - this.filePreviewState.target.remotePath : '', - activeFilePreviewLoading: route === AppRoute.RemoteChat && this.filePreviewState.visible && - this.filePreviewState.phase === FilePreviewPhase.Loading, - showSidebarButton: showSidebarButton, - showBackButton: showBackButton, - showSidebarRestoreButton: showSidebarRestoreButton, - composerPresentation: useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Compact, - contentHorizontalOffset: useWidePresentation ? this.collapsedDetailVisualBias() : 0, - onRestoreSidebar: () => { - this.restoreWideMasterPane(); - }, - onIntent: (intent: ConversationIntent) => this.actions.onConversationIntent(route, intent) - }) - } - }.width('100%').height('100%').backgroundColor(PAGE_BG) - } - - @Builder - WideGeneralChatContent(route: AppRoute) { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.General, false) - this.WideMasterDetailGap() - } - this.WideConversationDetail(route, false) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - WideRemoteHomeContent() { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.Remote, false) - this.WideMasterDetailGap() - } - - Column() { - this.RemoteFlowPlaceholder() - } - .layoutWeight(1) - .height('100%') - .backgroundColor(PAGE_BG) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - WideRemoteCreateContent() { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.Remote, false) - this.WideMasterDetailGap() - } - this.WideConversationDetail(AppRoute.RemoteCreate, false) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - /** - * The single wide master pane shell. Local and Remote differ only in the - * session content they hand to the shared sidebar, so the header, source - * switcher, content origin and footer never move when the source changes. - */ - @Builder - WideMasterPane(source: ConversationSource, showSelectedSession: boolean) { - Column() { - Column() { - AppSidebar({ - sessions: source === ConversationSource.Remote ? [] : this.generalPageState.recentSessions(), - pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: source === ConversationSource.Remote ? '' : - this.generalPageState.activeSession.sessionId, - connectionState: this.remotePageState.connectionState, - accountUserId: this.remotePageState.accountUserId, - activeSection: source === ConversationSource.Remote ? 'remote' : 'chat', - showConversationSourceSwitcher: true, - showCollapseButton: true, - showViewSettingsButton: source === ConversationSource.Remote, - showCustomContent: source === ConversationSource.Remote, - conversationSource: source, - contentSlot: () => { - this.RemoteMasterContent(showSelectedSession); - }, - onClose: this.actions.onSidebar.close, - onNewChat: source === ConversationSource.Remote ? - this.actions.onRemoteHome.createAssistant : this.actions.onSidebar.newChat, - onEnterCode: () => this.actions.onWideConversationSource(ConversationSource.Remote), - onConversationSource: this.actions.onWideConversationSource, - onCollapse: () => { - this.collapseWideMasterPane(); - }, - onOpenViewSettings: () => { - this.showRemoteViewSettings = true; - }, - onSearchQueryChange: (query: string) => { - if (source === ConversationSource.Remote) { - this.actions.onRemoteHome.queryChanged(query); - } - }, - onOpenSettings: source === ConversationSource.Remote ? - this.actions.onRemoteHome.openSettings : this.actions.onSidebar.settings, - onOpenAccount: this.actions.onSidebar.openAccount, - onOpenSession: this.actions.onSidebar.openSession, - onArchiveSession: this.actions.onSidebar.archive, - onExportSession: this.actions.onSidebar.exportSession, - onDeleteSession: this.actions.onSidebar.deleteSession - }) - } - .width('100%') - .height('100%') - .backgroundColor(FLOATING_PANEL_BG) - .borderRadius(18) - .clip(true) - .shadow({ radius: 24, color: '#14000000', offsetX: 4, offsetY: 8 }) - } - .width(this.wideMasterPaneCurrentWidth()) - .height('100%') - .padding({ left: 10, right: 6, top: 10, bottom: 10 }) - .backgroundColor(PAGE_BG) - .transition(this.wideMasterPaneMotionActive ? - TransitionEffect.translate({ x: -28, y: 0 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 220, curve: Curve.EaseInOut }) : - TransitionEffect.opacity(1)) - } - - /** - * Remote session content for the shared sidebar shell. The wide master pane - * opens sessions in place next to the list; the compact drawer has to close - * itself and navigate, so every entry point is routed through a compact flag - * instead of a second copy of the list. - */ - @Builder - RemoteMasterContent(showSelectedSession: boolean, compact: boolean = false) { - Column() { - this.RemoteStatusRow() - if (this.isRemoteInitialLoading()) { - RemoteSessionLoadingView() - } else if (this.canShowRemoteSessionList()) { - RemoteSessionList({ - sessions: this.remotePageState.visibleSessions(), - query: this.remotePageState.sessionQuery, - sortMode: this.remoteWideSortMode, - workspaceFilter: this.remoteWorkspaceFilter, - agentFilter: this.remoteAgentFilter, - statusFilter: this.remoteStatusFilter, - workspaceName: this.remotePageState.workspaceName, - workspacePath: this.remotePageState.workspacePath, - workspaceKind: this.remotePageState.workspaceKind, - recentWorkspaces: this.remotePageState.recentWorkspaces, - actionPresentation: SessionActionPresentation.Popover, - showWorkspaceMetadata: this.showRemoteWorkspaceMetadata, - showUpdatedMetadata: this.showRemoteUpdatedMetadata, - showStatusMetadata: this.showRemoteStatusMetadata, - hasMoreSessions: this.remotePageState.hasMoreSessions, - isBusy: this.remotePageState.isBusy || this.remotePageState.isLoadingSessions, - selectedSessionId: showSelectedSession ? this.remotePageState.activeSession.sessionId : '', - onCreate: () => { - this.createRemoteSession('code', compact); - }, - onCreateAssistantSession: () => { - this.createRemoteAssistantSession(compact); - }, - onCreateInWorkspace: (path: string, agentType: string) => { - this.createRemoteSessionInWorkspace(path, agentType, compact); - }, - onSelectWorkspace: (path: string) => { - this.actions.onRemoteHome.selectWorkspace(path); - }, - onOpenSession: (session: RemoteSession) => { - this.openRemoteSession(session, compact); - }, - onDeleteSession: (session: RemoteSession) => { - this.actions.onRemoteHome.deleteSession(session); - }, - onLoadMore: () => { - this.actions.onRemoteHome.loadMore(); - } - }) - } else { - this.RemoteDisconnectedState() - } - } - .width('100%') - .height('100%') - .alignItems(HorizontalAlign.Start) - .padding({ bottom: 84 }) - } - - /** Connection status lives in the remote content, not in the shared header. */ - @Builder - RemoteStatusRow() { - Row({ space: 6 }) { - this.RemoteStatusIndicator() - Text(this.remoteStatusText()) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) + ConversationRouteSurface({ + route, + remotePageState: this.remotePageState, + remoteCreateState: this.remoteCreateState, + generalPageState: this.generalPageState, + filePreviewState: this.filePreviewState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + showSidebarButton: true, + // Compact conversations own the drawer, not a back control: Local and + // Remote both open the sidebar over the chat instead of leaving it. + showBackButton: false, + onRestoreSidebar: () => this.restoreWideMasterPane() + }) } - .width('100%') - .margin({ top: 16, bottom: 6 }) - .alignItems(VerticalAlign.Center) } @Builder RemoteViewSettingsSheet() { - ConversationViewSettings({ - sessions: this.remotePageState.visibleSessions(), - workspaceName: this.remotePageState.workspaceName, - workspacePath: this.remotePageState.workspacePath, - workspaceKind: this.remotePageState.workspaceKind, - recentWorkspaces: this.remotePageState.recentWorkspaces, - sortMode: this.remoteWideSortMode, - workspaceFilter: this.remoteWorkspaceFilter, - agentFilter: this.remoteAgentFilter, - statusFilter: this.remoteStatusFilter, - showWorkspaceMetadata: this.showRemoteWorkspaceMetadata, - showUpdatedMetadata: this.showRemoteUpdatedMetadata, - showStatusMetadata: this.showRemoteStatusMetadata, - onSortModeChange: (mode: string) => { - this.remoteWideSortMode = mode; - }, - onWorkspaceFilterChange: (value: string) => { - RemoteLogger.info(`wide view-settings workspace received=${value.length > 0 ? value : ''}`); - this.remoteWorkspaceFilter = value; - }, - onAgentFilterChange: (value: string) => { - this.remoteAgentFilter = value; - }, - onStatusFilterChange: (value: string) => { - this.remoteStatusFilter = value; - }, - onWorkspaceMetadataChange: (value: boolean) => { - this.showRemoteWorkspaceMetadata = value; - }, - onUpdatedMetadataChange: (value: boolean) => { - this.showRemoteUpdatedMetadata = value; - }, - onStatusMetadataChange: (value: boolean) => { - this.showRemoteStatusMetadata = value; - }, - onClose: () => { - this.showRemoteViewSettings = false; - } + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Settings, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + onCloseSettings: () => { this.showRemoteViewSettings = false; } }) } - @Builder - RemoteStatusIndicator() { - if (this.isRemoteInitialLoading()) { - LoadingProgress() - .width(14) - .height(14) - .color(MUTED) - } else { - Stack() { - Text('') - } - .width(7) - .height(7) - .backgroundColor(this.remoteStatusColor()) - .borderRadius(4) - } - } - - @Builder - RemoteDisconnectedState() { - Column({ space: 12 }) { - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.desktop')) - .fontSize(42) - .fontColor([INK]) - } - .width(74) - .height(74) - .backgroundColor(CARD) - .borderRadius(24) - .border({ width: 1, color: LINE }) - Text(RemoteI18n.t('remote.connectTitle')) - .fontSize(18) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .textAlign(TextAlign.Center) - Text(RemoteI18n.t('remote.connectText')) - .fontSize(13) - .lineHeight(20) - .fontColor(MUTED) - .textAlign(TextAlign.Center) - Text(RemoteI18n.t('connect.connect')) - .width(136) - .height(44) - .fontSize(15) - .fontColor(PRIMARY_ACTION_TEXT) - .backgroundColor(PRIMARY_ACTION) - .textAlign(TextAlign.Center) - .borderRadius(22) - .onClick(() => { - this.actions.onRemoteHome.connectWorkspace(); - }) - } - .layoutWeight(1) - .width('100%') - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ left: 20, right: 20, bottom: 48 }) - } - - @Builder - WideRemoteChatContent() { - if (this.filePreviewPlacement() === FilePreviewPlacement.WideTriplePane) { - Row() { - this.WideMasterPane(ConversationSource.Remote, true) - this.WidePaneGap(this.filePreviewLayout().masterConversationGap) - this.WideConversationDetail( - AppRoute.RemoteChat, - false, - this.filePreviewLayout().conversationPaneWidth - ) - this.WidePaneGap(this.filePreviewLayout().conversationPreviewGap) - this.FilePreviewPane(this.filePreviewLayout().previewPaneWidth) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } else { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.Remote, true) - this.WideMasterDetailGap() - } - this.WideConversationDetail(AppRoute.RemoteChat, false) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - } - - @Builder - WideRemotePreviewFocusContent() { - Row() { - this.WideConversationDetail( - AppRoute.RemoteChat, - false, - this.filePreviewLayout().conversationPaneWidth - ) - this.WidePaneGap(this.filePreviewLayout().conversationPreviewGap) - this.FilePreviewPane(this.filePreviewLayout().previewPaneWidth) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - @Builder FilePreviewPane(paneWidth: number = 0) { Column() { @@ -816,232 +218,10 @@ export struct AppRootPresentation { .backgroundColor(PAGE_BG) } - @Builder - WideConversationDetail(route: AppRoute, showBackButton: boolean, paneWidth: number = 0) { - if (paneWidth > 0) { - Column() { - this.RouteSurfaceContent(route, false, showBackButton, false, true) - } - .width(paneWidth) - .height('100%') - .constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) - .backgroundColor(PAGE_BG) - } else { - Stack({ alignContent: Alignment.TopStart }) { - Row() { - if (this.currentDetailContentOffset() > 0) { - Blank().width(this.currentDetailContentOffset()) - } - Row() { - Column() { - this.RouteSurfaceContent(route, false, showBackButton, false, true) - } - .width('100%') - .height('100%') - .constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) - .backgroundColor(PAGE_BG) - } - .width(this.currentDetailContentWidth() > 0 ? this.currentDetailContentWidth() : '100%') - .height('100%') - .justifyContent(FlexAlign.Center) - if (this.currentDetailContentOffset() > 0) { - Blank().layoutWeight(1) - } - } - .width('100%') - .height('100%') - .justifyContent(FlexAlign.Center) - .backgroundColor(PAGE_BG) - - if (this.wideMasterPaneCollapsed) { - SidebarToggleButton({ - restore: true, - controlSize: 44, - onToggle: () => { - this.restoreWideMasterPane(); - } - }) - .position({ x: this.currentDetailContentOffset() + 12, y: 12 }) - .zIndex(2) - .transition(TransitionEffect.scale({ x: 0.9, y: 0.9 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 180, curve: Curve.EaseOut })) - } - } - .layoutWeight(1) - .height('100%') - .backgroundColor(PAGE_BG) - } - } - - @Builder - WideMasterDetailGap() { - if (this.wideMasterDetailGap > 0) { - Row() { - } - .width(this.wideMasterDetailGap) - .height('100%') - .backgroundColor(LINE) - } - } - - @Builder - WidePaneGap(width: number) { - if (width > 0) { - Row() { - } - .width(width) - .height('100%') - .backgroundColor(LINE) - } - } - - /** - * Compact Remote landing surface. The session list lives in the shared drawer - * now, so this route only carries connection state and the way back into the - * drawer — the same shape the Local composer route has. - */ - @Builder - CompactRemoteHomeContent() { - Column() { - GeneralChatHeader({ - title: RemoteI18n.t('remote.title'), - showSidebarButton: true, - onOpenSidebar: this.actions.onRemoteHome.openSidebar - }) - if (this.canShowRemoteSessionList()) { - this.CompactRemoteEmptyState() - } else { - this.RemoteDisconnectedState() - } - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - CompactRemoteEmptyState() { - Column({ space: 10 }) { - if (this.isRemoteInitialLoading()) { - LoadingProgress() - .width(28) - .height(28) - .color(MUTED) - .margin({ bottom: 8 }) - } - Text(this.compactRemoteEmptyTitle()) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .textAlign(TextAlign.Center) - Text(this.compactRemoteEmptyText()) - .fontSize(14) - .lineHeight(21) - .fontColor(MUTED) - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .textAlign(TextAlign.Center) - .constraintSize({ maxWidth: 280 }) - Text(RemoteI18n.t('remote.startSession')) - .width(148) - .height(46) - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor(PRIMARY_ACTION_TEXT) - .backgroundColor(PRIMARY_ACTION) - .textAlign(TextAlign.Center) - .borderRadius(23) - .margin({ top: 12 }) - .onClick(() => { - this.actions.onRemoteHome.createAssistant(); - }) - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ left: 24, right: 24, bottom: 56 }) - } - - @Builder - RemoteFlowPlaceholder() { - Column() { - Row({ space: 8 }) { - if (this.wideMasterPaneCollapsed) { - SidebarToggleButton({ - restore: true, - controlSize: 48, - onToggle: () => { - this.restoreWideMasterPane(); - } - }) - } else { - Blank().width(48).height(48) - } - Column({ space: 4 }) { - Text(RemoteI18n.t('remote.chats')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Text(this.remoteDesktopName()) - .fontSize(13) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Center) - Blank().width(48).height(48) - } - .width('100%') - .height(76) - .padding({ left: 16, right: 16, top: 14, bottom: 12 }) - .border({ width: { bottom: 1 }, color: LINE }) - - Column({ space: 8 }) { - if (this.isRemoteInitialLoading()) { - LoadingProgress() - .width(28) - .height(28) - .color(MUTED) - .margin({ bottom: 8 }) - } - Text(this.remoteFlowPlaceholderTitle()) - .fontSize(22) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Text(this.remoteStatusText()) - .fontSize(14) - .fontColor(MUTED) - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ left: 24, right: 24, bottom: 48 }) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - private isWideLayout(): boolean { return this.largeScreenLayout; } - /** - * Read inside the master pane builder rather than passed in: a @Builder only - * re-renders on parameters passed by reference, so a width handed over as a - * value would freeze at whatever the pane measured on its first render. - */ - private wideMasterPaneCurrentWidth(): number { - return this.filePreviewPlacement() === FilePreviewPlacement.WideTriplePane ? - this.filePreviewLayout().masterPaneWidth : this.wideMasterPaneWidth; - } - private collapseWideMasterPane(): void { if (!this.isWideLayout() || this.filePreviewState.visible) { return; @@ -1067,24 +247,6 @@ export struct AppRootPresentation { }, 240); } - private currentDetailContentOffset(): number { - return this.wideMasterPaneCollapsed ? - this.wideCollapsedDetailContentOffset : this.wideDetailContentOffset; - } - - private currentDetailContentWidth(): number { - return this.wideMasterPaneCollapsed ? - this.wideCollapsedDetailContentWidth : this.wideDetailContentWidth; - } - - private collapsedDetailVisualBias(): number { - if (!this.wideMasterPaneCollapsed || this.wideCollapsedDetailContentOffset > 0) { - return 0; - } - const availableMargin = (this.wideCollapsedDetailContentWidth - WIDE_DETAIL_CONTENT_MAX_WIDTH) / 2; - return Math.min(72, Math.max(0, availableMargin)); - } - private filePreviewPlacement(): FilePreviewPlacement { return this.filePreviewLayout().placement; } @@ -1099,16 +261,9 @@ export struct AppRootPresentation { ); } - private isGeneralWideRoute(route: AppRoute): boolean { - return route === AppRoute.ChatHome || route === AppRoute.GeneralChat; - } - - private showsWideRemoteConversation(route: AppRoute): boolean { - if (!this.isWideLayout()) { - return false; - } - return route === AppRoute.RemoteChat || - (route === AppRoute.RemoteHome && this.remotePageState.activeSession.sessionId.length > 0); + private isConversationRoute(route: AppRoute): boolean { + return route === AppRoute.ChatHome || route === AppRoute.GeneralChat || + route === AppRoute.RemoteHome || route === AppRoute.RemoteCreate || route === AppRoute.RemoteChat; } private bindResponsiveQueries(): void { @@ -1153,8 +308,7 @@ export struct AppRootPresentation { } private areaWidth(width: Object): number { - const value = Number.parseFloat(`${width}`); - return Number.isNaN(value) ? 0 : value; + return WideLayoutGeometry.areaLength(width); } private refreshWideGeometry(): void { @@ -1178,6 +332,7 @@ export struct AppRootPresentation { this.wideDetailContentWidth = geometry.detailContentWidth; this.wideCollapsedDetailContentOffset = geometry.collapsedDetailContentOffset; this.wideCollapsedDetailContentWidth = geometry.collapsedDetailContentWidth; + this.actions.onLayoutModeChanged(this.largeScreenLayout); if (wasWideLayout && !this.largeScreenLayout) { this.actions.onCompactLayoutEntered(); } @@ -1202,126 +357,6 @@ export struct AppRootPresentation { } } - /** - * Session entry points shared by the wide master pane and the compact drawer. - * The wide pane keeps the list on screen and swaps the detail pane; the - * compact drawer has to dismiss itself first and then navigate. - */ - private openRemoteSession(session: RemoteSession, compact: boolean): void { - if (compact) { - this.actions.onSidebar.openSession(session); - return; - } - this.actions.onRemoteHome.openSessionInPlace(session); - } - - private createRemoteSession(agentType: string, compact: boolean): void { - if (compact) { - this.actions.onSidebar.close(); - this.actions.onRemoteHome.create(agentType); - return; - } - this.actions.onRemoteHome.createInPlace(agentType); - } - - private createRemoteSessionInWorkspace(path: string, agentType: string, compact: boolean): void { - if (compact) { - this.actions.onSidebar.close(); - this.actions.onRemoteHome.createInWorkspace(path, agentType); - return; - } - this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType); - } - - private createRemoteAssistantSession(compact: boolean): void { - if (compact) { - this.actions.onSidebar.close(); - } - this.actions.onRemoteHome.createAssistant(); - } - - private compactSidebarSource(): ConversationSource { - return AppRouteContract.conversationSource(this.shellState.activeRoute); - } - - /** The compact drawer's new-chat and settings entries follow the active source. */ - private compactSidebarNewChat(source: ConversationSource): void { - if (source === ConversationSource.Remote) { - this.createRemoteAssistantSession(true); - return; - } - this.actions.onSidebar.newChat(); - } - - private compactSidebarSettings(source: ConversationSource): void { - if (source === ConversationSource.Remote) { - this.actions.onSidebar.close(); - this.actions.onRemoteHome.openSettings(); - return; - } - this.actions.onSidebar.settings(); - } - - private canShowRemoteSessionList(): boolean { - return this.remotePageState.connectionState === 'connected' || this.remotePageState.visibleSessions().length > 0 || - this.remotePageState.isLoadingHome || this.remotePageState.isLoadingSessions; - } - - private isRemoteInitialLoading(): boolean { - return this.remotePageState.isLoadingHome || this.isRemoteConnecting(); - } - - private isRemoteConnecting(): boolean { - return this.remotePageState.connectionState === 'parsing' || - this.remotePageState.connectionState === 'pairing' || - this.remotePageState.connectionState === 'reconnecting'; - } - - private remoteStatusText(): string { - if (this.remotePageState.statusText.length > 0) { - return this.remotePageState.statusText; - } - return this.remoteDesktopName(); - } - - private remoteStatusColor(): ResourceColor { - if (this.remotePageState.connectionState === 'connected') { - return GREEN; - } - if (this.remotePageState.connectionState === 'failed' || this.remotePageState.connectionState === 'disconnected') { - return RED; - } - return MUTED; - } - - private remoteDesktopName(): string { - return this.remotePageState.desktopName.length > 0 ? this.remotePageState.desktopName : - RemoteI18n.t('remote.settings.noDesktop'); - } - - private compactRemoteEmptyTitle(): string { - if (this.isRemoteInitialLoading()) { - return RemoteI18n.t('common.loading'); - } - return this.remotePageState.visibleSessions().length > 0 ? - RemoteI18n.t('remote.pickSession') : RemoteI18n.t('remote.emptyTitle'); - } - - private compactRemoteEmptyText(): string { - if (this.isRemoteInitialLoading()) { - return this.remoteStatusText(); - } - return this.remotePageState.visibleSessions().length > 0 ? - RemoteI18n.t('remote.pickSessionText') : RemoteI18n.t('remote.emptyText'); - } - - private remoteFlowPlaceholderTitle(): string { - if (this.isRemoteInitialLoading()) { - return RemoteI18n.t('common.loading'); - } - return this.remotePageState.visibleSessions().length > 0 ? '选择会话' : RemoteI18n.t('remote.emptyTitle'); - } - private remoteViewSettingsSheetOptions(): SheetOptions { if (!this.isWideLayout()) { return { @@ -1343,107 +378,32 @@ export struct AppRootPresentation { }; } - /** - * The compact drawer runs the same sidebar shell as the wide master pane, so - * Local and Remote are two sources inside one session container instead of a - * drawer and a separate destination page. The drawer outlives every route - * change, and a @Builder does not re-render on value parameters, so the source - * is read from the current route on each render instead of being passed in. - */ @Builder SidebarContent() { - AppSidebar({ - sessions: this.compactSidebarSource() === ConversationSource.Remote ? - [] : this.generalPageState.recentSessions(), - pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: this.compactSidebarSource() === ConversationSource.Remote ? '' : - (AppRouteContract.isGeneralComposerRoute(this.shellState.activeRoute) ? - this.generalPageState.activeSession.sessionId : ''), - connectionState: this.remotePageState.connectionState, - accountUserId: this.remotePageState.accountUserId, - activeSection: this.compactSidebarSource() === ConversationSource.Remote ? 'remote' : 'chat', - showConversationSourceSwitcher: true, - showViewSettingsButton: this.compactSidebarSource() === ConversationSource.Remote, - showCustomContent: this.compactSidebarSource() === ConversationSource.Remote, - conversationSource: this.compactSidebarSource(), - contentSlot: () => { - this.RemoteMasterContent(true, true); - }, - onClose: this.actions.onSidebar.close, - onNewChat: () => { - this.compactSidebarNewChat(this.compactSidebarSource()); - }, - onEnterCode: this.actions.onSidebar.enterCode, - onConversationSource: this.actions.onCompactConversationSource, - onOpenViewSettings: () => { - this.showRemoteViewSettings = true; - }, - onSearchQueryChange: (query: string) => { - if (this.compactSidebarSource() === ConversationSource.Remote) { - this.actions.onRemoteHome.queryChanged(query); - } - }, - onOpenSettings: () => { - this.compactSidebarSettings(this.compactSidebarSource()); - }, - onOpenAccount: this.actions.onSidebar.openAccount, - onOpenSession: this.actions.onSidebar.openSession, - onArchiveSession: this.actions.onSidebar.archive, - onExportSession: this.actions.onSidebar.exportSession, - onDeleteSession: this.actions.onSidebar.deleteSession + AppSidebarSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + generalPageState: this.generalPageState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + onOpenRemoteViewSettings: () => { this.showRemoteViewSettings = true; } }) } @Builder SettingsContent() { - if (this.shellState.settingsMode === 'remote' || this.shellState.settingsMode === 'account') { - RemoteControlSettingsSheet({ desktopName: this.remotePageState.desktopName, desktopId: this.remotePageState.desktopId, - userId: this.remotePageState.userId, accountUsername: this.remotePageState.accountUsername, - accountUserId: this.remotePageState.accountUserId, deviceId: this.deviceId, - controlTargetType: this.remotePageState.controlTargetType, - controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, - connectionState: this.remotePageState.connectionState, statusText: this.remotePageState.statusText, - isBusy: this.remotePageState.isBusy, onClose: this.actions.onSettings.close, - onOpenAccount: this.actions.onSettings.openAccount, - onAddConnection: this.actions.onSettings.addConnection, - cloudLogin: this.actions.onSettings.cloudLogin, - cloudSync: this.actions.onSettings.cloudSync, - cloudLogout: this.actions.onSettings.cloudLogout, - cloudListDevices: this.actions.onSettings.cloudListDevices, - getPermissionMode: this.actions.onSettings.getPermissionMode, - setPermissionMode: this.actions.onSettings.setPermissionMode, - openAccountOnAppear: this.shellState.settingsMode === 'account', - onDisconnect: this.actions.onSettings.disconnect, onReconnect: this.actions.onSettings.reconnect }) - } else { - SettingsSheet({ generalChatApiUrl: this.generalPageState.apiUrl, generalChatModelName: this.generalPageState.modelName, - hasGeneralChatApiKey: this.generalPageState.hasApiKey, - generalChatModelCatalog: this.generalPageState.modelCatalog, - selectedGeneralChatModelId: this.generalPageState.selectedModelId, - accountUsername: this.remotePageState.accountUsername, - authenticatedUserId: this.remotePageState.accountUserId, + AppSettingsSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + generalPageState: this.generalPageState, deviceId: this.deviceId, - onOpenAccount: this.actions.onSettings.openAccount, - onTestGeneralChatConfig: this.actions.onSettings.testGeneral, - onSaveGeneralChatConfig: this.actions.onSettings.saveGeneral, - onClose: this.actions.onSettings.close }) - } + actions: this.actions + }) } @Builder ConnectContent() { - ConnectView({ remoteUrl: this.remotePageState.remoteUrl, userId: this.remotePageState.userId, - showRemoteUrlInput: this.remotePageState.showRemoteUrlInput, statusText: this.remotePageState.statusText, - connectionState: this.remotePageState.connectionState, connectionFailureKind: this.remotePageState.connectionFailureKind, - isBusy: this.remotePageState.isBusy, isConnected: this.remotePageState.connectionState === 'connected', - desktopName: this.remotePageState.desktopName, desktopId: this.remotePageState.desktopId, deviceId: this.deviceId, - accountUserId: this.remotePageState.accountUserId, - controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, - requiresAccountAuth: this.remotePageState.requiresAccountAuth, accountUsername: this.remotePageState.accountUsername, - startWithScanner: true, - onBack: this.actions.onConnect.back, onConnect: this.actions.onConnect.connect, - onClearPairing: this.actions.onConnect.clearPairing, onRemoteUrlChange: this.actions.onConnect.urlChanged, - onUserIdChange: this.actions.onConnect.userChanged, onRemoteUrlDetected: this.actions.onConnect.detected, - onRemoteUrlInputVisibleChange: this.actions.onConnect.inputVisible, - onPasteRemoteUrl: this.actions.onConnect.paste, onScanRemoteUrl: this.actions.onConnect.scan, - cloudListDevices: this.actions.onConnect.cloudListDevices, - cloudSelectDevice: this.actions.onConnect.cloudSelectDevice }) - .width('100%').height('100%') + AppConnectSurface({ + remotePageState: this.remotePageState, + deviceId: this.deviceId, + actions: this.actions + }) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index 08cec8c99..6e02ff01b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -5,43 +5,44 @@ import { ConversationSource } from '../navigation/AppRouteContract'; import { ConversationSourceSwitcher } from './ConversationSourceSwitcher'; import { SidebarToggleButton } from './SidebarToggleButton'; import { SessionActionPresentation, SessionActionSurface } from './SessionActionSurface'; -import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../state/SessionActionPolicy'; +import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../policy/SessionActionPolicy'; import { SessionDetailsView } from './SessionDetailsView'; +import { SidebarGlyph } from './SidebarGlyphs'; -@Component +@ComponentV2 export struct AppSidebar { - @Prop sessions: RemoteSession[] = []; - @Prop pinnedSessionId: string = ''; - @Prop selectedSessionId: string = ''; - @Prop connectionState: string = 'idle'; - @Prop activeSection: string = 'chat'; - @Prop accountUserId: string = ''; - @Prop showConversationSourceSwitcher: boolean = false; - @Prop showCollapseButton: boolean = false; - @Prop showViewSettingsButton: boolean = false; - @Prop showCustomContent: boolean = false; - @Prop conversationSource: ConversationSource = ConversationSource.General; - onClose: () => void = () => {}; - onNewChat: () => void = () => {}; - onEnterCode: () => void = () => {}; - onConversationSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; - onCollapse: () => void = () => {}; - onOpenViewSettings: () => void = () => {}; - onSearchQueryChange: (query: string) => void = (_query: string) => {}; - onOpenSettings: () => void = () => {}; - onOpenAccount: () => void = () => {}; - onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - onArchiveSession: (session: RemoteSession, archived: boolean) => void = + @Param sessions: RemoteSession[] = []; + @Param pinnedSessionId: string = ''; + @Param selectedSessionId: string = ''; + @Param connectionState: string = 'idle'; + @Param activeSection: string = 'chat'; + @Param accountUserId: string = ''; + @Param showConversationSourceSwitcher: boolean = false; + @Param showCollapseButton: boolean = false; + @Param showViewSettingsButton: boolean = false; + @Param showCustomContent: boolean = false; + @Param conversationSource: ConversationSource = ConversationSource.General; + @Event onClose: () => void = () => {}; + @Event onNewChat: () => void = () => {}; + @Event onEnterCode: () => void = () => {}; + @Event onConversationSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; + @Event onCollapse: () => void = () => {}; + @Event onOpenViewSettings: () => void = () => {}; + @Event onSearchQueryChange: (query: string) => void = (_query: string) => {}; + @Event onOpenSettings: () => void = () => {}; + @Event onOpenAccount: () => void = () => {}; + @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; + @Event onArchiveSession: (session: RemoteSession, archived: boolean) => void = (_session: RemoteSession, _archived: boolean) => {}; - onExportSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @State activeActionSessionId: string = ''; - @State showSessionActionSheet: boolean = false; - @State detailsSessionId: string = ''; - @State showSessionDetails: boolean = false; - @State showSearch: boolean = false; - @State sessionSearchQuery: string = ''; - @State archivedSessionsExpanded: boolean = false; + @Event onExportSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; + @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; + @Local activeActionSessionId: string = ''; + @Local showSessionActionSheet: boolean = false; + @Local detailsSessionId: string = ''; + @Local showSessionDetails: boolean = false; + @Local showSearch: boolean = false; + @Local sessionSearchQuery: string = ''; + @Local archivedSessionsExpanded: boolean = false; /** * Session content for the current conversation source. The shell around it * (header, source switcher, content origin, footer) stays identical for every @@ -180,7 +181,7 @@ export struct AppSidebar { Row({ space: 6 }) { if (this.showViewSettingsButton) { Stack({ alignContent: Alignment.Center }) { - this.MoreDotsGlyph() + SidebarGlyph({ kind: 'session_more' }) } .width(38) .height(38) @@ -195,7 +196,7 @@ export struct AppSidebar { } Stack({ alignContent: Alignment.Center }) { - this.SearchGlyph() + SidebarGlyph({ kind: 'search' }) } .width(38) .height(38) @@ -282,7 +283,7 @@ export struct AppSidebar { private AuthenticatedFooter() { Row() { Row({ space: 9 }) { - this.EditGlyph() + SidebarGlyph({ kind: 'edit' }) Text(RemoteI18n.t('sidebar.newChat')) .fontSize(15) .fontWeight(FontWeight.Medium) @@ -303,7 +304,7 @@ export struct AppSidebar { Blank() Stack({ alignContent: Alignment.Center }) { - this.SettingsGlyph() + SidebarGlyph({ kind: 'settings' }) } .width(46) .height(46) @@ -338,7 +339,7 @@ export struct AppSidebar { @Builder NavRow(label: string, isActive: boolean, action: () => void) { Row({ space: 14 }) { - this.RemoteGlyph() + SidebarGlyph({ kind: 'remote', connectionState: this.connectionState }) Text(label) .fontSize(18) .fontWeight(FontWeight.Bold) @@ -476,21 +477,10 @@ export struct AppSidebar { }) } - @Builder - private MoreDotsGlyph() { - Row({ space: 3 }) { - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - } - .height(8) - .alignItems(VerticalAlign.Center) - } - @Builder private SessionMoreButton(session: RemoteSession) { Stack({ alignContent: Alignment.Center }) { - this.MoreDotsGlyph() + SidebarGlyph({ kind: 'session_more' }) } .width(34) .height(40) @@ -558,205 +548,6 @@ export struct AppSidebar { .padding({ top: 8, bottom: 8 }) } - @Builder - RemoteGlyph() { - Stack({ alignContent: Alignment.Center }) { - if (this.connectionState === 'connected' || this.connectionState === 'reconnecting') { - Image($r('app.media.remote_ref_sidebar_connected')) - .width(35) - .height(34) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(INK) - Text('') - .width(8) - .height(8) - .backgroundColor(GREEN) - .borderRadius(4) - .position({ x: 24, y: 22 }) - } else { - Image($r('app.media.remote_logo')) - .width(34) - .height(34) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(MUTED) - } - } - .width(35) - .height(34) - } - - @Builder - SearchGlyph() { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(22) - .fontColor([INK]) - .width(24) - .height(24) - } - - @Builder - NotebookGlyph() { - Stack() { - Text('') - .width(22) - .height(24) - .borderRadius(5) - .border({ width: 1.5, color: INK }) - .position({ x: 8, y: 5 }) - Text('') - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor(INK) - .position({ x: 5, y: 11 }) - Text('') - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor(INK) - .position({ x: 5, y: 20 }) - } - .width(34) - .height(34) - } - - @Builder - ClockGlyph() { - Stack() { - Text('') - .width(26) - .height(26) - .borderRadius(13) - .border({ width: 1.5, color: INK }) - .position({ x: 4, y: 4 }) - Text('') - .width(1.5) - .height(9) - .backgroundColor(INK) - .borderRadius(2) - .position({ x: 18, y: 10 }) - Text('') - .width(9) - .height(1.5) - .backgroundColor(INK) - .borderRadius(2) - .position({ x: 18, y: 20 }) - } - .width(34) - .height(34) - } - - @Builder - AppsGlyph() { - Column({ space: 8 }) { - Row({ space: 8 }) { - this.AppDot() - this.AppDot() - } - Row({ space: 8 }) { - this.AppDot() - this.AppDot() - } - } - .width(24) - .height(24) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - } - - @Builder - AppDot() { - Text('') - .width(8) - .height(8) - .borderRadius(4) - .backgroundColor(INK) - } - - @Builder - CodeFlowerGlyph() { - Stack() { - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 9, y: 1 }) - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 17, y: 9 }) - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 9, y: 17 }) - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 1, y: 9 }) - Text('') - .width(14) - .height(14) - .borderRadius(7) - .backgroundColor(CARD) - .position({ x: 10, y: 10 }) - } - .width(34) - .height(34) - } - - @Builder - MoreGlyph() { - Row({ space: 5 }) { - this.DotGlyph() - this.DotGlyph() - this.DotGlyph() - } - .width(30) - .height(22) - .justifyContent(FlexAlign.Center) - .alignItems(VerticalAlign.Center) - } - - @Builder - DotGlyph() { - Text('') - .width(5) - .height(5) - .borderRadius(3) - .backgroundColor(INK) - } - - @Builder - EditGlyph() { - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(22) - .fontColor([INK]) - .width(24) - .height(24) - } - - @Builder - SettingsGlyph() { - SymbolGlyph($r('sys.symbol.gearshape')) - .fontSize(22) - .fontColor([INK]) - .width(24) - .height(24) - } - private visibleRecentSessions(): RemoteSession[] { const query = this.sessionSearchQuery.trim().toLowerCase(); return this.sessions.filter((session: RemoteSession) => { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets index 38375e438..c1a6917fd 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets @@ -2,17 +2,17 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { DEFAULT_CLOUD_RELAY_URL } from '../../services/CloudAccountClient'; import { CARD, INK, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SUBTLE } from './Theme'; -@Component +@ComponentV2 export struct BitFunAccountLoginPage { - cloudLogin: (relayUrl: string, username: string, password: string) => Promise = + @Event cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; - onBack: () => void = () => {}; - onLoginSuccess: () => void = () => {}; - @State relayUrl: string = DEFAULT_CLOUD_RELAY_URL; - @State username: string = ''; - @State password: string = ''; - @State errorText: string = ''; - @State isBusy: boolean = false; + @Event onBack: () => void = () => {}; + @Event onLoginSuccess: () => void = () => {}; + @Local relayUrl: string = DEFAULT_CLOUD_RELAY_URL; + @Local username: string = ''; + @Local password: string = ''; + @Local errorText: string = ''; + @Local isBusy: boolean = false; build() { Stack({ alignContent: Alignment.TopStart }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index b2b3e8531..66628abaa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -1,16 +1,10 @@ import { ConversationUiImage, ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; -import { FileReferenceCard } from './FileReferenceCard'; -import { MarkdownContent } from './MarkdownContent'; -import { StreamingMarkdownContent } from './StreamingMarkdownContent'; +import { INK, LINE, MUTED, SOFT } from './Theme'; +import { MessageFileCards, MessageImageGallery, MessageMarkdown } from './ChatMessageContent'; +import { ChatMessageRetryAction, ChatTypingDots, ChatUserMessageBubble } from './ChatMessageChrome'; import { ThinkingBlock } from './ThinkingBlock'; import { ToolStatusList } from './ToolStatusList'; -import { FileTargetResolver } from '../../services/FileTargetResolver'; -import { - MessageFileReference, - MessageFileReferenceProjectionCache -} from '../../services/MessageFileReferenceProjector'; +import { MessageFileReference, MessageFileReferenceProjectionCache } from '../../services/MessageFileReferenceProjector'; interface SubagentTaskInput { description?: string; @@ -28,13 +22,6 @@ interface StructuredRenderGroup { path: string; } -interface ActivityGroupStats { - thinkingCount: number; - readCount: number; - searchCount: number; - otherCount: number; -} - @ComponentV2 export struct ChatMessageBubble { @Param item: ConversationUiMessage = { @@ -63,83 +50,21 @@ export struct ChatMessageBubble { @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; - @Local expandedActivityPath: string = ''; - @Local typingPhase: number = 0; - private typingTimerId: number = 0; private readonly fileReferenceCache: MessageFileReferenceProjectionCache = new MessageFileReferenceProjectionCache(); - aboutToAppear(): void { - if (!this.shouldShowTypingDots(this.item) && !this.hasRunningSubagentTask(this.item)) { - return; - } - this.typingTimerId = setInterval(() => { - this.typingPhase = (this.typingPhase + 1) % 3; - }, 360); - } - - aboutToDisappear(): void { - if (this.typingTimerId !== 0) { - clearInterval(this.typingTimerId); - this.typingTimerId = 0; - } - } - build() { if (this.item.role === 'user') { - this.UserBubble() + ChatUserMessageBubble({ + item: this.item, + showRetryAction: this.showRetryAction, + onRetryMessage: this.onRetryMessage + }) } else { this.AssistantBubble() } } - @Builder - UserBubble() { - Row() { - Blank() - Column({ space: 6 }) { - if (this.visibleMessageText(this.item).length > 0 || (this.item.images && this.item.images.length > 0)) { - Column({ space: 8 }) { - if (this.item.images && this.item.images.length > 0) { - this.UserMessageImages(this.item.images) - } - if (this.visibleMessageText(this.item).length > 0) { - Text(this.visibleMessageText(this.item)) - .fontSize(14) - .lineHeight(20) - .fontColor(INK) - } - } - .padding({ left: 10, right: 10, top: 10, bottom: 10 }) - .backgroundColor(SOFT) - .borderRadius(18) - .alignItems(HorizontalAlign.Start) - } - if (this.item.status === 'failed' && this.showRetryAction) { - Row({ space: 8 }) { - Text(RemoteI18n.t('chat.sendFailed')) - .fontSize(12) - .fontColor(RED) - Text(RemoteI18n.t('common.retry')) - .fontSize(12) - .fontColor(PRIMARY_ACTION_TEXT) - .height(28) - .padding({ left: 10, right: 10 }) - .backgroundColor(ACCENT) - .borderRadius(14) - .onClick(() => { - this.onRetryMessage(this.item.text); - }) - } - } - } - .constraintSize({ maxWidth: '70%' }) - .alignItems(HorizontalAlign.End) - } - .width('100%') - .padding({ top: 8, bottom: 12 }) - } - @Builder AssistantBubble() { Row() { @@ -154,21 +79,11 @@ export struct ChatMessageBubble { } if (this.item.status === 'failed' && this.showRetryAction && (this.item.detail || '').trim().length > 0) { - Row({ space: 8 }) { - Text(RemoteI18n.t('generalChat.replyInterrupted')) - .fontSize(12) - .fontColor(RED) - Text(RemoteI18n.t('common.retry')) - .fontSize(12) - .fontColor(PRIMARY_ACTION_TEXT) - .height(28) - .padding({ left: 10, right: 10 }) - .backgroundColor(ACCENT) - .borderRadius(14) - .onClick(() => { - this.onRetryMessage(this.item.detail || '') - }) - } + ChatMessageRetryAction({ + assistant: true, + retryText: this.item.detail || '', + onRetry: this.onRetryMessage + }) } } .layoutWeight(1) @@ -203,7 +118,7 @@ export struct ChatMessageBubble { } } if (this.shouldShowTypingDots(item)) { - this.TypingDots() + ChatTypingDots() } if (item.tools && item.tools.length > 0) { this.Tools(item.tools) @@ -219,28 +134,6 @@ export struct ChatMessageBubble { } } - @Builder - AssistantAvatar() { - Row({ space: 4 }) { - Text('') - .width(6) - .height(6) - .backgroundColor(PRIMARY_ACTION_TEXT) - .borderRadius(3) - Text('') - .width(6) - .height(6) - .backgroundColor(PRIMARY_ACTION_TEXT) - .borderRadius(3) - } - .width(32) - .height(32) - .backgroundColor(ACCENT) - .borderRadius(16) - .justifyContent(FlexAlign.Center) - .alignItems(VerticalAlign.Center) - } - @Builder StructuredItems(items: ConversationUiMessageItem[], omitActiveThinking: boolean = false) { Column({ space: 10 }) { @@ -357,7 +250,7 @@ export struct ChatMessageBubble { } .width('100%') if (entry.tool && this.isRunningTool(entry.tool)) { - this.TypingDots() + ChatTypingDots() } if (entry.content && entry.content.trim().length > 0 && !this.isTextEntry(entry) && !this.isThinkingEntry(entry)) { this.MessageText(entry.content, activeScope && (!entry.subItems || entry.subItems.length === 0), `${this.item.id}-${path}-subagent`) @@ -422,122 +315,38 @@ export struct ChatMessageBubble { }) } - @Builder - TypingDots() { - Row({ space: 5 }) { - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.typingDotOpacity(0)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.typingDotOpacity(1)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.typingDotOpacity(2)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - } - .height(24) - .padding({ left: 2 }) - } - - private typingDotOpacity(index: number): number { - return this.typingPhase === index ? 1.0 : 0.34; - } - @Builder MessageImages(images: ConversationUiImage[]) { - Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { - ForEach(images, (image: ConversationUiImage) => { - Image(image.data_url) - .width(92) - .height(92) - .objectFit(ImageFit.Cover) - .borderRadius(14) - .border({ width: 1, color: LINE }) - .margin({ right: 8, bottom: 8 }) - }, (image: ConversationUiImage) => image.name) - } - .width('100%') - } - - @Builder - UserMessageImages(images: ConversationUiImage[]) { - Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { - ForEach(images, (image: ConversationUiImage, index: number) => { - Image(image.data_url) - .width(112) - .height(112) - .objectFit(ImageFit.Cover) - .borderRadius(12) - .border({ width: 1, color: LINE }) - .margin({ right: index % 2 === 0 && images.length > 1 ? 8 : 0, bottom: index < images.length - 2 ? 8 : 0 }) - }, (image: ConversationUiImage, index: number) => `${image.name}-${index}`) - } - .width(images.length > 1 ? 232 : 112) + MessageImageGallery({ images }) } @Builder MessageText(text: string, active: boolean = false, streamKey: string = '') { - if (active) { - StreamingMarkdownContent({ - text, - active, - streamKey, - onCopyText: (body: string) => { - this.onCopyMessage(body); - }, - onOpenLink: (reference: string, label: string) => { - this.onOpenFilePreview(reference, label); - } - }) - } else { - MarkdownContent({ - text, - onCopyText: (body: string) => { - this.onCopyMessage(body); - }, - onOpenLink: (reference: string, label: string) => { - this.onOpenFilePreview(reference, label); - } - }) - } + MessageMarkdown({ + text, + active, + streamKey, + onCopyText: this.onCopyMessage, + onOpenLink: this.onOpenFilePreview + }) } @Builder FileCards(text: string) { - Column({ space: 8 }) { - ForEach(this.fileReferences(text), (file: MessageFileReference) => { - FileReferenceCard({ - path: file.path, - label: file.label, - status: this.fileStatus(file.path), - previewLabel: RemoteI18n.t('common.open'), - buttonLabel: this.fileButtonLabel(file.path), - disabled: this.downloadingFilePath === file.path, - selected: FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), - previewLoading: this.activeFilePreviewLoading && - FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), - onPreview: (path: string, label: string) => { - this.onOpenFilePreview(path, label); - }, - onDownload: (path: string) => { - this.onDownloadFile(path); - } - }) - }, (file: MessageFileReference) => file.id) - } - .width('100%') + MessageFileCards({ + text, + downloadingFilePath: this.downloadingFilePath, + downloadedFilePath: this.downloadedFilePath, + fileDownloadStatus: this.fileDownloadStatus, + activeFilePreviewPath: this.activeFilePreviewPath, + activeFilePreviewLoading: this.activeFilePreviewLoading, + onPreview: this.onOpenFilePreview, + onDownload: this.onDownloadFile + }) + } + + private fileReferences(text: string): MessageFileReference[] { + return this.fileReferenceCache.referencesFor(text); } private visibleMessageText(item: ConversationUiMessage): string { @@ -896,56 +705,6 @@ export struct ChatMessageBubble { return ''; } - private activityGroupTitle(group: StructuredRenderGroup): string { - const stats = this.activityGroupStats(group.items); - const total = stats.thinkingCount + stats.readCount + stats.searchCount + stats.otherCount; - return `已折叠 ${total} 个思考和工具调用`; - } - - private activityGroupDetail(group: StructuredRenderGroup): string { - const stats = this.activityGroupStats(group.items); - const parts: string[] = []; - if (stats.thinkingCount > 0) { - parts.push(`思考 ${stats.thinkingCount}`); - } - if (stats.readCount > 0) { - parts.push(`读取 ${stats.readCount}`); - } - if (stats.searchCount > 0) { - parts.push(`搜索 ${stats.searchCount}`); - } - if (stats.otherCount > 0) { - parts.push(`其他 ${stats.otherCount}`); - } - return parts.join(' · '); - } - - private activityGroupStats(items: ConversationUiMessageItem[]): ActivityGroupStats { - const stats: ActivityGroupStats = { - thinkingCount: 0, - readCount: 0, - searchCount: 0, - otherCount: 0 - }; - items.forEach((entry: ConversationUiMessageItem) => { - if (this.isThinkingEntry(entry)) { - stats.thinkingCount += 1; - return; - } - if (entry.tool) { - const kind = this.activityToolKind(entry.tool); - if (kind === 'read') { - stats.readCount += 1; - } else if (kind === 'search') { - stats.searchCount += 1; - } else { - stats.otherCount += 1; - } - } - }); - return stats; - } - private activityGroupTools(group: StructuredRenderGroup): ConversationUiToolStatus[] { const tools: ConversationUiToolStatus[] = []; group.items.forEach((entry: ConversationUiMessageItem) => { @@ -1141,13 +900,6 @@ export struct ChatMessageBubble { return ''; } - private hasRunningSubagentTask(item: ConversationUiMessage): boolean { - return (item.items || []).some((entry: ConversationUiMessageItem) => { - return !!entry.tool && this.normalizedToolName(entry.tool) === 'task' && - this.isRunningTool(entry.tool); - }); - } - private structuredItemKey(entry: ConversationUiMessageItem, path: string): string { if (entry.tool && entry.tool.id) { return `${path}-tool-${entry.tool.id}`; @@ -1217,30 +969,4 @@ export struct ChatMessageBubble { normalized === 'ask_user_question'; } - private fileReferences(text: string): MessageFileReference[] { - return this.fileReferenceCache.referencesFor(text); - } - - private fileStatus(path: string): string { - if ((this.downloadingFilePath === path || this.downloadedFilePath === path) && this.fileDownloadStatus.length > 0) { - return this.fileDownloadStatus; - } - if (path.indexOf('computer://') === 0) { - return RemoteI18n.t('chat.desktopFile'); - } - if (path.indexOf('file://') === 0) { - return RemoteI18n.t('chat.fileLink'); - } - return path; - } - - private fileButtonLabel(path: string): string { - if (this.downloadingFilePath === path) { - return RemoteI18n.t('chat.reading'); - } - if (this.downloadedFilePath === path) { - return RemoteI18n.t('common.done'); - } - return RemoteI18n.t('chat.download'); - } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets new file mode 100644 index 000000000..c485c0add --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets @@ -0,0 +1,109 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConversationUiMessage } from './ConversationUiModels'; +import { MessageImageGallery } from './ChatMessageContent'; +import { ACCENT, INK, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; + +@ComponentV2 +export struct ChatTypingDots { + @Local phase: number = 0; + private timerId: number = 0; + + aboutToAppear(): void { + this.timerId = setInterval(() => { + this.phase = (this.phase + 1) % 3; + }, 360); + } + + aboutToDisappear(): void { + if (this.timerId !== 0) { + clearInterval(this.timerId); + this.timerId = 0; + } + } + + build() { + Row({ space: 5 }) { + ForEach([0, 1, 2], (index: number) => { + Text('•') + .width(6) + .height(18) + .fontSize(16) + .fontColor(MUTED) + .opacity(this.phase === index ? 1.0 : 0.34) + .animation({ duration: 180, curve: Curve.EaseInOut }) + }) + } + .height(24) + .padding({ left: 2 }) + } +} + +@ComponentV2 +export struct ChatMessageRetryAction { + @Param assistant: boolean = false; + @Param retryText: string = ''; + @Event onRetry: (text: string) => void = (_text: string) => {}; + + build() { + Row({ space: 8 }) { + Text(this.assistant ? RemoteI18n.t('generalChat.replyInterrupted') : RemoteI18n.t('chat.sendFailed')) + .fontSize(12) + .fontColor(RED) + Text(RemoteI18n.t('common.retry')) + .fontSize(12) + .fontColor(PRIMARY_ACTION_TEXT) + .height(28) + .padding({ left: 10, right: 10 }) + .backgroundColor(ACCENT) + .borderRadius(14) + .onClick(() => this.onRetry(this.retryText)) + } + } +} + +@ComponentV2 +export struct ChatUserMessageBubble { + @Param item: ConversationUiMessage = { + id: '', + role: 'user', + text: '', + status: '', + detail: '' + }; + @Param showRetryAction: boolean = false; + @Event onRetryMessage: (text: string) => void = (_text: string) => {}; + + build() { + Row() { + Blank() + Column({ space: 6 }) { + if (this.visibleText().length > 0 || (this.item.images && this.item.images.length > 0)) { + Column({ space: 8 }) { + if (this.item.images && this.item.images.length > 0) { + MessageImageGallery({ images: this.item.images, userStyle: true }) + } + if (this.visibleText().length > 0) { + Text(this.visibleText()).fontSize(14).lineHeight(20).fontColor(INK) + } + } + .padding({ left: 10, right: 10, top: 10, bottom: 10 }) + .backgroundColor(SOFT) + .borderRadius(18) + .alignItems(HorizontalAlign.Start) + } + if (this.item.status === 'failed' && this.showRetryAction) { + ChatMessageRetryAction({ retryText: this.item.text, onRetry: this.onRetryMessage }) + } + } + .constraintSize({ maxWidth: '70%' }) + .alignItems(HorizontalAlign.End) + } + .width('100%') + .padding({ top: 8, bottom: 12 }) + } + + private visibleText(): string { + const text = this.item.text.trim(); + return text === '(空消息)' || text === '(empty message)' ? '' : text; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets new file mode 100644 index 000000000..10df9e331 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets @@ -0,0 +1,111 @@ +import { ConversationUiImage } from './ConversationUiModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { FileTargetResolver } from '../../services/FileTargetResolver'; +import { + MessageFileReference, + MessageFileReferenceProjectionCache +} from '../../services/MessageFileReferenceProjector'; +import { FileReferenceCard } from './FileReferenceCard'; +import { MarkdownContent } from './MarkdownContent'; +import { StreamingMarkdownContent } from './StreamingMarkdownContent'; +import { LINE } from './Theme'; + +@ComponentV2 +export struct MessageImageGallery { + @Param images: ConversationUiImage[] = []; + @Param userStyle: boolean = false; + + build() { + Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { + ForEach(this.images, (image: ConversationUiImage, index: number) => { + Image(image.data_url) + .width(this.userStyle ? 112 : 92) + .height(this.userStyle ? 112 : 92) + .objectFit(ImageFit.Cover) + .borderRadius(this.userStyle ? 12 : 14) + .border({ width: 1, color: LINE }) + .margin({ + right: this.userStyle ? (index % 2 === 0 && this.images.length > 1 ? 8 : 0) : 8, + bottom: this.userStyle ? (index < this.images.length - 2 ? 8 : 0) : 8 + }) + }, (image: ConversationUiImage, index: number) => `${image.name}-${index}`) + } + .width(this.userStyle ? (this.images.length > 1 ? 232 : 112) : '100%') + } +} + +@ComponentV2 +export struct MessageMarkdown { + @Param text: string = ''; + @Param active: boolean = false; + @Param streamKey: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = + (_reference: string, _label: string) => {}; + + build() { + if (this.active) { + StreamingMarkdownContent({ + text: this.text, + active: this.active, + streamKey: this.streamKey, + onCopyText: this.onCopyText, + onOpenLink: this.onOpenLink + }) + } else { + MarkdownContent({ + text: this.text, + onCopyText: this.onCopyText, + onOpenLink: this.onOpenLink + }) + } + } +} + +@ComponentV2 +export struct MessageFileCards { + @Param text: string = ''; + @Param downloadingFilePath: string = ''; + @Param downloadedFilePath: string = ''; + @Param fileDownloadStatus: string = ''; + @Param activeFilePreviewPath: string = ''; + @Param activeFilePreviewLoading: boolean = false; + @Event onPreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; + @Event onDownload: (path: string) => void = (_path: string) => {}; + private readonly cache: MessageFileReferenceProjectionCache = new MessageFileReferenceProjectionCache(); + + build() { + Column({ space: 8 }) { + ForEach(this.cache.referencesFor(this.text), (file: MessageFileReference) => { + FileReferenceCard({ + path: file.path, + label: file.label, + status: this.fileStatus(file.path), + previewLabel: RemoteI18n.t('common.open'), + buttonLabel: this.fileButtonLabel(file.path), + disabled: this.downloadingFilePath === file.path, + selected: FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), + previewLoading: this.activeFilePreviewLoading && + FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), + onPreview: this.onPreview, + onDownload: this.onDownload + }) + }, (file: MessageFileReference) => file.id) + } + .width('100%') + } + + private fileStatus(path: string): string { + if ((this.downloadingFilePath === path || this.downloadedFilePath === path) && + this.fileDownloadStatus.length > 0) return this.fileDownloadStatus; + if (path.indexOf('computer://') === 0) return RemoteI18n.t('chat.desktopFile'); + if (path.indexOf('file://') === 0) return RemoteI18n.t('chat.fileLink'); + return path; + } + + private fileButtonLabel(path: string): string { + if (this.downloadingFilePath === path) return RemoteI18n.t('chat.reading'); + if (this.downloadedFilePath === path) return RemoteI18n.t('common.done'); + return RemoteI18n.t('chat.download'); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets index e61d366e2..aa21d5e3f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets @@ -1,13 +1,13 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { INK, LINE, MUTED, PAGE_BG, SOFT } from './Theme'; -@Component +@ComponentV2 export struct ChatStatusBar { - @Prop title: string = ''; - @Prop detail: string = ''; - @Prop color: ResourceColor = MUTED; - @Prop canStop: boolean = false; - onStop: () => void = () => {}; + @Param title: string = ''; + @Param detail: string = ''; + @Param color: ResourceColor = MUTED; + @Param canStop: boolean = false; + @Event onStop: () => void = () => {}; build() { Row({ space: 10 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index 8f2d8f4ba..2c0aceb71 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -7,7 +7,7 @@ import { ConversationUiModelCatalog, ConversationUiSelectedImage } from './ConversationUiModels'; -import { ConversationModelPresentationPolicy } from '../state/ConversationModelPresentationPolicy'; +import { ConversationModelPresentationPolicy } from '../policy/ConversationModelPresentationPolicy'; import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, RED, SOFT } from './Theme'; export enum ComposerPresentation { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets new file mode 100644 index 000000000..e1e87a723 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets @@ -0,0 +1,245 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PAGE_BG, RED, SOFT } from './Theme'; + +@ComponentV2 +export struct ConnectAccountDevicePage { + @Param deviceId: string = ''; + @Param controlTargetDeviceId: string = ''; + @Param connectionState: string = 'idle'; + @Event onBack: () => void = () => {}; + @Event onOpenScanner: () => void = () => {}; + @Event cloudListDevices: () => Promise = + async (): Promise => []; + @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = + async (_device: CloudAccountDevice): Promise => {}; + @Local accountDevices: CloudAccountDevice[] = []; + @Local accountDevicesBusy: boolean = false; + @Local accountDevicesError: string = ''; + @Local switchingDeviceId: string = ''; + @Local otherConnectionMethodsExpanded: boolean = false; + + aboutToAppear(): void { + this.refreshAccountDevices(); + } + + build() { + Column() { + Row({ space: 16 }) { + Stack() { + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(23).fontColor([INK]).width(26).height(26) + } + .width(48).height(48).backgroundColor(SOFT).borderRadius(24) + .onClick(() => this.onBack()) + Column({ space: 4 }) { + Text(RemoteI18n.t('connect.accountDevicesTitle')) + .fontSize(22).fontWeight(FontWeight.Bold).fontColor(INK).width('100%') + Text(RemoteI18n.t('connect.accountDevicesSubtitle')) + .fontSize(13).fontColor(MUTED).width('100%') + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%').height(92) + .padding({ left: 28, right: 28, top: 18 }) + .alignItems(VerticalAlign.Top) + + Scroll() { + Column({ space: 18 }) { + Text(RemoteI18n.t('connect.accountDevicesBody')) + .fontSize(14).lineHeight(21).fontColor(MUTED).width('100%') + this.AccountDeviceList() + this.OtherConnectionMethods() + } + .width('100%') + .constraintSize({ minHeight: '100%' }) + .padding({ left: 28, right: 28, top: 10, bottom: 34 }) + } + .layoutWeight(1) + .width('100%') + .scrollBar(BarState.Off) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private AccountDeviceList() { + Column({ space: 4 }) { + Row() { + Text(RemoteI18n.t('connect.availableDevices')) + .fontSize(16).fontWeight(FontWeight.Bold).fontColor(INK) + Blank() + Text(this.accountDevicesBusy ? RemoteI18n.t('common.loading') : + (this.accountDevicesError.length > 0 ? RemoteI18n.t('common.retry') : RemoteI18n.t('common.refresh'))) + .fontSize(14) + .fontColor(this.accountDevicesBusy ? MUTED : + (this.accountDevicesError.length > 0 ? RED : ACCENT)) + .onClick(async () => { await this.refreshAccountDevices(); }) + } + .width('100%').height(38) + + if (this.accountDevicesBusy && this.accountDevices.length === 0) { + Column() { + this.AccountDeviceSkeletonRow() + this.AccountDeviceSkeletonRow() + } + .width('100%').height(120) + } else if (this.desktopDevices().length === 0) { + Row() { + Text(this.accountDevicesError || RemoteI18n.t('remote.settings.deviceEmpty')) + .fontSize(14).lineHeight(20).fontColor(MUTED).width('100%') + } + .width('100%').height(120).alignItems(VerticalAlign.Center) + } else { + Scroll() { + Column() { + ForEach(this.desktopDevices(), (device: CloudAccountDevice) => { + this.AccountConnectDeviceRow(device) + }, (device: CloudAccountDevice): string => + `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}`) + } + .width('100%') + } + .width('100%').height(120).scrollBar(BarState.Off) + } + } + .width('100%').height(174) + .padding({ left: 16, right: 16, top: 8, bottom: 8 }) + .backgroundColor(CARD).borderRadius(8).border({ width: 1, color: LINE }) + } + + @Builder + private OtherConnectionMethods() { + Column() { + Row({ space: 12 }) { + Text(RemoteI18n.t('connect.otherConnectionMethods')) + .fontSize(16).fontWeight(FontWeight.Medium).fontColor(INK) + Blank() + SymbolGlyph(this.otherConnectionMethodsExpanded ? + $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) + .fontSize(13).fontColor([MUTED]) + } + .width('100%').height(58).padding({ left: 16, right: 16 }) + .onClick(() => { + this.otherConnectionMethodsExpanded = !this.otherConnectionMethodsExpanded; + }) + + if (this.otherConnectionMethodsExpanded) { + Divider().color(LINE).margin({ left: 16, right: 16 }) + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.link')) + .fontSize(20).fontColor([MUTED]).width(22).height(22).opacity(0.66) + Text(RemoteI18n.t('connect.scanPairCodeAction')) + .fontSize(16).fontWeight(FontWeight.Medium).fontColor(INK).layoutWeight(1) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) + } + .width('100%').height(58).padding({ left: 16, right: 16 }) + .onClick(() => this.onOpenScanner()) + } + } + .width('100%').backgroundColor(CARD).borderRadius(8).border({ width: 1, color: LINE }) + } + + @Builder + private AccountDeviceSkeletonRow() { + Row({ space: 12 }) { + Text('').width(26).height(22).backgroundColor(SOFT).borderRadius(5) + Column({ space: 7 }) { + Text('').width('58%').height(12).backgroundColor(SOFT).borderRadius(4) + Text('').width(52).height(9).backgroundColor(SOFT).borderRadius(4) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%').height(60).padding({ left: 4, right: 4 }).alignItems(VerticalAlign.Center) + } + + @Builder + private AccountConnectDeviceRow(device: CloudAccountDevice) { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(22).fontColor([MUTED]).width(26).height(24).opacity(device.online ? 0.68 : 0.38) + Column({ space: 3 }) { + Text(device.deviceName || device.deviceId) + .fontSize(15).fontWeight(FontWeight.Medium).fontColor(INK) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.accountDeviceStatus(device)) + .fontSize(13).fontColor(device.online ? GREEN : MUTED) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + if (device.online) { + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) + } + } + .width('100%').height(60).padding({ left: 4, right: 4 }) + .alignItems(VerticalAlign.Center) + .opacity(this.canSelectAccountDevice(device) ? 1 : 0.64) + .onClick(async () => { + if (!this.canSelectAccountDevice(device)) return; + this.switchingDeviceId = device.deviceId; + this.accountDevicesError = ''; + try { + await this.cloudSelectDevice(device); + } catch (err) { + this.accountDevicesError = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceSwitchFailed'); + } finally { + this.switchingDeviceId = ''; + } + }) + } + + private async refreshAccountDevices(): Promise { + if (this.accountDevicesBusy) return; + this.accountDevicesBusy = true; + this.accountDevicesError = ''; + try { + this.accountDevices = await this.cloudListDevices(); + } catch (err) { + this.accountDevicesError = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceLoadFailed'); + } finally { + this.accountDevicesBusy = false; + if (!this.hasOnlineDesktopDevice()) { + this.otherConnectionMethodsExpanded = true; + } + } + } + + private desktopDevices(): CloudAccountDevice[] { + return this.accountDevices.filter((device: CloudAccountDevice): boolean => + device.deviceId !== this.deviceId && device.deviceName !== 'HarmonyOS Phone'); + } + + private canSelectAccountDevice(device: CloudAccountDevice): boolean { + return device.online && device.deviceId !== this.deviceId && this.switchingDeviceId.length === 0; + } + + private hasOnlineDesktopDevice(): boolean { + const devices = this.desktopDevices(); + for (let index = 0; index < devices.length; index += 1) { + if (devices[index].online) return true; + } + return false; + } + + private accountDeviceStatus(device: CloudAccountDevice): string { + if (device.deviceId === this.switchingDeviceId) { + return RemoteI18n.t('remote.settings.deviceConnecting'); + } + const presence = device.online ? RemoteI18n.t('remote.settings.deviceOnline') : + RemoteI18n.t('remote.settings.deviceOffline'); + if (device.deviceId === this.controlTargetDeviceId && this.connectionState === 'connected') { + return `${RemoteI18n.t('remote.settings.deviceControlling')} · ${presence}`; + } + if (device.deviceId === this.controlTargetDeviceId) { + return `${RemoteI18n.t('connect.deviceLastUsed')} · ${presence}`; + } + return presence; + } +} + diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets new file mode 100644 index 000000000..e1dd2ccf1 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets @@ -0,0 +1,107 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, LINE, MODAL_SCRIM, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, SOFT, SUBTLE } from './Theme'; + +@ComponentV2 +export struct ConnectManualPairingOverlay { + @Param remoteUrl: string = ''; + @Param userIdInput: string = ''; + @Param password: string = ''; + @Param requiresAccountAuth: boolean = false; + @Param canSubmit: boolean = false; + @Event onRemoteUrlChange: (value: string) => void = (_value: string) => {}; + @Event onUserIdChange: (value: string) => void = (_value: string) => {}; + @Event onPasswordChange: (value: string) => void = (_value: string) => {}; + @Event onCancel: () => void = () => {}; + @Event onSubmit: () => void = () => {}; + + build() { + Stack() { + Text('') + .width('100%') + .height('100%') + .backgroundColor(MODAL_SCRIM) + .onClick(this.onCancel) + + Column({ space: 20 }) { + Text(this.requiresAccountAuth ? + RemoteI18n.t('connect.accountPairTitle') : RemoteI18n.t('connect.manualPair')) + .fontSize(24) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .width('100%') + Text(this.requiresAccountAuth ? + RemoteI18n.t('connect.accountPairIntro') : RemoteI18n.t('connect.manualPairBody')) + .fontSize(17) + .lineHeight(24) + .fontColor(MUTED) + .width('100%') + TextInput({ placeholder: RemoteI18n.t('connect.pairCodePlaceholder'), text: this.remoteUrl }) + .height(62) + .fontSize(20) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(31) + .padding({ left: 20, right: 20 }) + .defaultFocus(true) + .onChange(this.onRemoteUrlChange) + if (this.requiresAccountAuth) { + TextInput({ + placeholder: RemoteI18n.t('connect.accountUsernamePlaceholder'), + text: this.userIdInput + }) + .height(56) + .fontSize(18) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(28) + .padding({ left: 20, right: 20 }) + .onChange(this.onUserIdChange) + TextInput({ placeholder: RemoteI18n.t('connect.accountPasswordPlaceholder'), text: this.password }) + .height(56) + .fontSize(18) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(28) + .padding({ left: 20, right: 20 }) + .type(InputType.Password) + .onChange(this.onPasswordChange) + Text(RemoteI18n.t('connect.accountPairBody')) + .fontSize(13) + .lineHeight(18) + .fontColor(MUTED) + .width('100%') + } + Row({ space: 12 }) { + Button(RemoteI18n.t('common.cancel')) + .layoutWeight(1) + .height(58) + .fontSize(19) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(29) + .onClick(this.onCancel) + Button(RemoteI18n.t('connect.pair')) + .layoutWeight(1) + .height(58) + .fontSize(19) + .fontWeight(FontWeight.Bold) + .fontColor(this.canSubmit ? PRIMARY_ACTION_TEXT : SUBTLE) + .backgroundColor(this.canSubmit ? PRIMARY_ACTION : SOFT) + .borderRadius(29) + .enabled(this.canSubmit) + .onClick(this.onSubmit) + } + .width('100%') + } + .width('82%') + .constraintSize({ maxWidth: 520 }) + .padding({ left: 28, right: 28, top: 30, bottom: 28 }) + .backgroundColor(CARD) + .borderRadius(34) + .border({ width: 1, color: LINE }) + } + .width('100%') + .height('100%') + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets index 8b917cecd..b01a2402d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets @@ -2,64 +2,52 @@ import { abilityAccessCtrl, Context, Permissions } from '@kit.AbilityKit'; import { customScan, scanBarcode, scanCore } from '@kit.ScanKit'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ConnectAccountDevicePage } from './ConnectAccountDevicePage'; +import { ConnectManualPairingOverlay } from './ConnectManualPairingOverlay'; import { ACCENT, CARD, CONNECT_HERO_ACCENT, CONNECT_HERO_BG, CONNECT_HERO_SECONDARY, - CONNECT_HERO_SURFACE, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT, + CONNECT_HERO_SURFACE, CONNECT_SCAN_ACCENT, GREEN, INK, LINE, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT, SUBTLE } from './Theme'; -const CONNECT_SCAN_YELLOW: string = '#FFD021'; -const CONNECT_OVERLAY: string = '#99000000'; const CAMERA_PERMISSION: Permissions = 'ohos.permission.CAMERA'; -@Component +@ComponentV2 export struct ConnectView { private readonly scannerController: XComponentController = new XComponentController(); private scannerStarted: boolean = false; private scanCompleted: boolean = false; private scanStartRetryCount: number = 0; - @Prop remoteUrl: string = ''; - @Prop userId: string = ''; - @Prop showRemoteUrlInput: boolean = false; - @Prop statusText: string = RemoteI18n.t('status.waitingConnection'); - @Prop connectionState: string = 'idle'; - @Prop connectionFailureKind: string = ''; - @Prop isBusy: boolean = false; - @Prop isConnected: boolean = false; - @Prop desktopName: string = ''; - @Prop desktopId: string = ''; - @Prop deviceId: string = ''; - @Prop accountUserId: string = ''; - @Prop controlTargetDeviceId: string = ''; - @Prop requiresAccountAuth: boolean = false; - @Prop accountUsername: string = ''; - @Prop startWithScanner: boolean = true; - onBack: () => void = () => {}; - onConnect: (password?: string) => void = (_password?: string) => {}; - onClearPairing: () => void = () => {}; - onRemoteUrlChange: (value: string) => void = (_value: string) => {}; - onUserIdChange: (value: string) => void = (_value: string) => {}; - onRemoteUrlDetected: (value: string) => boolean = (_value: string) => false; - onRemoteUrlInputVisibleChange: (visible: boolean) => void = (_visible: boolean) => {}; - onPasteRemoteUrl: () => void = () => {}; - onScanRemoteUrl: () => void = () => {}; - cloudListDevices: () => Promise = async (): Promise => []; - cloudSelectDevice: (device: CloudAccountDevice) => Promise = + @Param remoteUrl: string = ''; + @Param userId: string = ''; + @Param statusText: string = RemoteI18n.t('status.waitingConnection'); + @Param connectionState: string = 'idle'; + @Param connectionFailureKind: string = ''; + @Param isBusy: boolean = false; + @Param isConnected: boolean = false; + @Param desktopName: string = ''; + @Param deviceId: string = ''; + @Param accountUserId: string = ''; + @Param controlTargetDeviceId: string = ''; + @Param requiresAccountAuth: boolean = false; + @Param accountUsername: string = ''; + @Param startWithScanner: boolean = true; + @Event onBack: () => void = () => {}; + @Event onConnect: (password?: string) => void = (_password?: string) => {}; + @Event onRemoteUrlChange: (value: string) => void = (_value: string) => {}; + @Event onUserIdChange: (value: string) => void = (_value: string) => {}; + @Event onRemoteUrlDetected: (value: string) => boolean = (_value: string) => false; + @Event onRemoteUrlInputVisibleChange: (visible: boolean) => void = (_visible: boolean) => {}; + @Event cloudListDevices: () => Promise = async (): Promise => []; + @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = async (_device: CloudAccountDevice): Promise => {}; - @State showHelp: boolean = false; - @State pairingStep: string = 'intro'; - @State showManualPairing: boolean = false; - @State inlineScanError: string = ''; - @State accountPassword: string = ''; - @State cameraPermissionReady: boolean = false; - @State requestingCameraPermission: boolean = false; - @State accountDevices: CloudAccountDevice[] = []; - @State accountDevicesBusy: boolean = false; - @State accountDevicesError: string = ''; - @State switchingDeviceId: string = ''; - @State otherConnectionMethodsExpanded: boolean = false; + @Local pairingStep: string = 'intro'; + @Local showManualPairing: boolean = false; + @Local inlineScanError: string = ''; + @Local accountPassword: string = ''; + @Local cameraPermissionReady: boolean = false; + @Local requestingCameraPermission: boolean = false; aboutToAppear(): void { if (this.isAccountAuthenticated()) { this.pairingStep = 'account'; - this.refreshAccountDevices(); } else if (this.startWithScanner && this.remoteUrl.trim().length === 0) { this.pairingStep = 'scan'; } @@ -89,251 +77,17 @@ export struct ConnectView { @Builder AccountDeviceSelectionPage() { - Column() { - Row({ space: 16 }) { - Stack() { - this.BackGlyph() - } - .width(48) - .height(48) - .backgroundColor(SOFT) - .borderRadius(24) - .onClick(() => { - this.stopInlineScan(); - this.onBack(); - }) - Column({ space: 4 }) { - Text(RemoteI18n.t('connect.accountDevicesTitle')) - .fontSize(22) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .width('100%') - Text(RemoteI18n.t('connect.accountDevicesSubtitle')) - .fontSize(13) - .fontColor(MUTED) - .width('100%') - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .width('100%') - .height(92) - .padding({ left: 28, right: 28, top: 18 }) - .alignItems(VerticalAlign.Top) - - Scroll() { - Column({ space: 18 }) { - Text(RemoteI18n.t('connect.accountDevicesBody')) - .fontSize(14) - .lineHeight(21) - .fontColor(MUTED) - .width('100%') - - this.AccountDeviceList() - this.OtherConnectionMethods() - } - .width('100%') - .constraintSize({ minHeight: '100%' }) - .padding({ left: 28, right: 28, top: 10, bottom: 34 }) - } - .layoutWeight(1) - .width('100%') - .scrollBar(BarState.Off) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - AccountDeviceList() { - Column({ space: 4 }) { - Row() { - Text(RemoteI18n.t('connect.availableDevices')) - .fontSize(16) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Blank() - Text(this.accountDevicesBusy ? RemoteI18n.t('common.loading') : - (this.accountDevicesError.length > 0 ? RemoteI18n.t('common.retry') : RemoteI18n.t('common.refresh'))) - .fontSize(14) - .fontColor(this.accountDevicesBusy ? MUTED : - (this.accountDevicesError.length > 0 ? RED : ACCENT)) - .onClick(async () => { - await this.refreshAccountDevices(); - }) - } - .width('100%') - .height(38) - - if (this.accountDevicesBusy && this.accountDevices.length === 0) { - Column() { - this.AccountDeviceSkeletonRow() - this.AccountDeviceSkeletonRow() - } - .width('100%') - .height(120) - } else if (this.desktopDevices().length === 0) { - Row() { - Text(this.accountDevicesError || RemoteI18n.t('remote.settings.deviceEmpty')) - .fontSize(14).lineHeight(20).fontColor(MUTED).width('100%') - } - .width('100%') - .height(120) - .alignItems(VerticalAlign.Center) - } else { - Scroll() { - Column() { - ForEach(this.desktopDevices(), (device: CloudAccountDevice) => { - this.AccountConnectDeviceRow(device) - }, (device: CloudAccountDevice): string => - `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}`) - } - .width('100%') - } - .width('100%') - .height(120) - .scrollBar(BarState.Off) - } - - } - .width('100%') - .height(174) - .padding({ left: 16, right: 16, top: 8, bottom: 8 }) - .backgroundColor(CARD) - .borderRadius(8) - .border({ width: 1, color: LINE }) - } - - @Builder - OtherConnectionMethods() { - Column() { - Row({ space: 12 }) { - Text(RemoteI18n.t('connect.otherConnectionMethods')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - if (this.otherConnectionMethodsExpanded) { - SymbolGlyph($r('sys.symbol.chevron_up')) - .fontSize(13) - .fontColor([MUTED]) - } else { - SymbolGlyph($r('sys.symbol.chevron_down')) - .fontSize(13) - .fontColor([MUTED]) - } - } - .width('100%') - .height(58) - .padding({ left: 16, right: 16 }) - .onClick(() => { - this.otherConnectionMethodsExpanded = !this.otherConnectionMethodsExpanded; - }) - - if (this.otherConnectionMethodsExpanded) { - Divider() - .color(LINE) - .margin({ left: 16, right: 16 }) - - Row({ space: 12 }) { - SymbolGlyph($r('sys.symbol.link')) - .fontSize(20) - .fontColor([MUTED]) - .width(22) - .height(22) - .opacity(0.66) - Text(RemoteI18n.t('connect.scanPairCodeAction')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .layoutWeight(1) - SymbolGlyph($r('sys.symbol.chevron_right')) - .fontSize(13) - .fontColor([MUTED]) - .width(16) - .height(16) - .opacity(0.44) - } - .width('100%') - .height(58) - .padding({ left: 16, right: 16 }) - .onClick(() => { - this.openScannerAfterPermission(); - }) - } - } - .width('100%') - .backgroundColor(CARD) - .borderRadius(8) - .border({ width: 1, color: LINE }) - } - - @Builder - AccountDeviceSkeletonRow() { - Row({ space: 12 }) { - Text('') - .width(26) - .height(22) - .backgroundColor(SOFT) - .borderRadius(5) - Column({ space: 7 }) { - Text('') - .width('58%') - .height(12) - .backgroundColor(SOFT) - .borderRadius(4) - Text('') - .width(52) - .height(9) - .backgroundColor(SOFT) - .borderRadius(4) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .width('100%') - .height(60) - .padding({ left: 4, right: 4 }) - .alignItems(VerticalAlign.Center) - } - - @Builder - AccountConnectDeviceRow(device: CloudAccountDevice) { - Row({ space: 12 }) { - SymbolGlyph($r('sys.symbol.desktop')) - .fontSize(22).fontColor([MUTED]).width(26).height(24).opacity(device.online ? 0.68 : 0.38) - Column({ space: 3 }) { - Text(device.deviceName || device.deviceId) - .fontSize(15).fontWeight(FontWeight.Medium).fontColor(INK) - .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.accountDeviceStatus(device)) - .fontSize(13).fontColor(device.online ? GREEN : MUTED) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - if (device.online) { - SymbolGlyph($r('sys.symbol.chevron_right')) - .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) - } - } - .width('100%') - .height(60) - .padding({ left: 4, right: 4 }) - .alignItems(VerticalAlign.Center) - .opacity(this.canSelectAccountDevice(device) ? 1 : 0.64) - .onClick(async () => { - if (!this.canSelectAccountDevice(device)) return; - this.switchingDeviceId = device.deviceId; - this.accountDevicesError = ''; - try { - await this.cloudSelectDevice(device); - } catch (err) { - this.accountDevicesError = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceSwitchFailed'); - } finally { - this.switchingDeviceId = ''; - } + ConnectAccountDevicePage({ + deviceId: this.deviceId, + controlTargetDeviceId: this.controlTargetDeviceId, + connectionState: this.connectionState, + cloudListDevices: this.cloudListDevices, + cloudSelectDevice: this.cloudSelectDevice, + onBack: () => { + this.stopInlineScan(); + this.onBack(); + }, + onOpenScanner: () => this.openScannerAfterPermission() }) } @@ -571,6 +325,27 @@ export struct ConnectView { .height(282) } + @Builder + ScanCorner(x: number, y: number, isLeft: boolean, isTop: boolean) { + Stack() { + Text('') + .width(42) + .height(4) + .borderRadius(2) + .backgroundColor(CONNECT_SCAN_ACCENT) + .position({ x: isLeft ? 0 : 22, y: isTop ? 0 : 60 }) + Text('') + .width(4) + .height(42) + .borderRadius(2) + .backgroundColor(CONNECT_SCAN_ACCENT) + .position({ x: isLeft ? 0 : 60, y: isTop ? 0 : 22 }) + } + .width(64) + .height(64) + .position({ x, y }) + } + @Builder PrimaryPairButton(text: string) { Button(text) @@ -622,373 +397,25 @@ export struct ConnectView { @Builder ManualPairingOverlay() { - Stack() { - Text('') - .width('100%') - .height('100%') - .backgroundColor(CONNECT_OVERLAY) - .onClick(() => { - this.stopInlineScan(); - this.showManualPairing = false; - this.resumeInlineScan(); - }) - - Column({ space: 20 }) { - Text(this.requiresAccountAuth ? RemoteI18n.t('connect.accountPairTitle') : RemoteI18n.t('connect.manualPair')) - .fontSize(24) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .width('100%') - Text(this.requiresAccountAuth ? RemoteI18n.t('connect.accountPairIntro') : RemoteI18n.t('connect.manualPairBody')) - .fontSize(17) - .lineHeight(24) - .fontColor(MUTED) - .width('100%') - TextInput({ placeholder: RemoteI18n.t('connect.pairCodePlaceholder'), text: this.remoteUrl }) - .height(62) - .fontSize(20) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(31) - .padding({ left: 20, right: 20 }) - .defaultFocus(true) - .onChange((value: string) => { - this.onRemoteUrlChange(value); - }) - if (this.requiresAccountAuth) { - TextInput({ placeholder: RemoteI18n.t('connect.accountUsernamePlaceholder'), text: this.displayUserIdInput() }) - .height(56) - .fontSize(18) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(28) - .padding({ left: 20, right: 20 }) - .onChange((value: string) => { - this.onUserIdChange(value); - }) - TextInput({ placeholder: RemoteI18n.t('connect.accountPasswordPlaceholder'), text: this.accountPassword }) - .height(56) - .fontSize(18) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(28) - .padding({ left: 20, right: 20 }) - .type(InputType.Password) - .onChange((value: string) => { - this.accountPassword = value; - }) - Text(RemoteI18n.t('connect.accountPairBody')) - .fontSize(13) - .lineHeight(18) - .fontColor(MUTED) - .width('100%') - } - Row({ space: 12 }) { - Button(RemoteI18n.t('common.cancel')) - .layoutWeight(1) - .height(58) - .fontSize(19) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(29) - .onClick(() => { - this.stopInlineScan(); - this.showManualPairing = false; - this.resumeInlineScan(); - }) - Button(RemoteI18n.t('connect.pair')) - .layoutWeight(1) - .height(58) - .fontSize(19) - .fontWeight(FontWeight.Bold) - .fontColor(this.canConnect() ? PRIMARY_ACTION_TEXT : SUBTLE) - .backgroundColor(this.canConnect() ? PRIMARY_ACTION : SOFT) - .borderRadius(29) - .enabled(this.canConnect()) - .onClick(() => { - this.ensureUserId(); - this.stopInlineScan(); - this.showManualPairing = false; - this.onConnect(this.accountPassword); - }) - } - .width('100%') - } - .width('82%') - .padding({ left: 28, right: 28, top: 30, bottom: 28 }) - .backgroundColor(CARD) - .borderRadius(34) - .border({ width: 1, color: LINE }) - } - .width('100%') - .height('100%') - } - - @Builder - HelpCard() { - Column({ space: 6 }) { - Text(RemoteI18n.t('connect.stepsTitle')) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - Text(RemoteI18n.t('connect.stepsBody')) - .fontSize(12) - .lineHeight(18) - .fontColor(MUTED) - .width('100%') - } - .padding(14) - .backgroundColor(SOFT) - .borderRadius(14) - .border({ width: 1, color: LINE }) - .width('100%') - } - - @Builder - ScanCard() { - Column({ space: 12 }) { - Row() { - Blank() - Stack() { - Text('') - .width(72) - .height(72) - .borderRadius(22) - .backgroundColor(SOFT) - this.ScanCorner(12, 12, true, true) - this.ScanCorner(32, 12, false, true) - this.ScanCorner(12, 32, true, false) - this.ScanCorner(32, 32, false, false) - } - .width(72) - .height(72) - Blank() - } - .width('100%') - .height(92) - - Text(RemoteI18n.t('connect.scanTitle')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - .textAlign(TextAlign.Center) - Text(RemoteI18n.t('connect.scanBody')) - .fontSize(13) - .lineHeight(20) - .fontColor(MUTED) - .width('100%') - .textAlign(TextAlign.Center) - } - .padding({ left: 18, right: 18, top: 26, bottom: 24 }) - .backgroundColor(CARD) - .borderRadius(16) - .width('100%') - .border({ width: 1, color: LINE }) - .onClick(() => { - this.onScanRemoteUrl(); - }) - } - - @Builder - ScanCorner(x: number, y: number, isLeft: boolean, isTop: boolean) { - Stack() { - Text('') - .width(42) - .height(4) - .borderRadius(2) - .backgroundColor(CONNECT_SCAN_YELLOW) - .position({ x: isLeft ? 0 : 22, y: isTop ? 0 : 60 }) - Text('') - .width(4) - .height(42) - .borderRadius(2) - .backgroundColor(CONNECT_SCAN_YELLOW) - .position({ x: isLeft ? 0 : 60, y: isTop ? 0 : 22 }) - } - .width(64) - .height(64) - .position({ x, y }) - } - - @Builder - RemoteUrlCard() { - Column({ space: 14 }) { - Row() { - Text(RemoteI18n.t('connect.userId')) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - Text(this.remoteUrl.trim().length > 0 ? RemoteI18n.t('connect.filled') : RemoteI18n.t('connect.remoteUrlShort')) - .fontSize(13) - .fontColor(MUTED) - .onClick(() => { - if (this.remoteUrl.trim().length > 0 || this.showRemoteUrlInput) { - this.onRemoteUrlInputVisibleChange(!this.showRemoteUrlInput); - } else { - this.onRemoteUrlInputVisibleChange(true); - this.onPasteRemoteUrl(); - } - }) - } - .width('100%') - - TextInput({ placeholder: RemoteI18n.t('connect.userPlaceholder'), text: this.displayUserIdInput() }) - .height(56) - .fontSize(15) - .backgroundColor(SOFT) - .borderRadius(14) - .padding({ left: 16, right: 16 }) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .onChange((value: string) => { - this.onUserIdChange(value); - }) - - if (this.showRemoteUrlInput) { - TextInput({ placeholder: RemoteI18n.t('connect.urlPlaceholder'), text: this.remoteUrl }) - .height(50) - .fontSize(13) - .backgroundColor(SOFT) - .borderRadius(14) - .padding(12) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .onChange((value: string) => { - this.onRemoteUrlChange(value); - }) - } - - Button(this.isBusy ? RemoteI18n.t('connect.connecting') : RemoteI18n.t('connect.connect')) - .width('100%') - .height(50) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(this.canConnect() ? PRIMARY_ACTION_TEXT : SUBTLE) - .backgroundColor(this.canConnect() ? PRIMARY_ACTION : SOFT) - .borderRadius(14) - .enabled(this.canConnect()) - .onClick(() => { - this.ensureUserId(); - this.onConnect(); - }) - } - .padding({ left: 18, right: 18, top: 18, bottom: 18 }) - .backgroundColor(CARD) - .borderRadius(16) - .width('100%') - .border({ width: 1, color: LINE }) - } - - @Builder - StatusCard() { - Column({ space: 8 }) { - Text(RemoteI18n.t('connect.statusTitle')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - .margin({ bottom: 6 }) - this.DesktopStatus() - if (this.isConnectError() && this.failureHint().length > 0) { - Divider().color(LINE) - this.FailureHint() - } - } - .width('100%') - .padding(16) - .backgroundColor(CARD) - .borderRadius(16) - .border({ width: 1, color: LINE }) - } - - @Builder - FailureHint() { - Text(this.failureHint()) - .fontSize(12) - .lineHeight(18) - .fontColor(INK) - .width('100%') - .padding(12) - .backgroundColor(SOFT) - .borderRadius(14) - .border({ width: 1, color: LINE }) - } - - @Builder - DesktopStatus() { - List() { - ListItem() { - this.DesktopStatusContent() - } - .height(74) - .swipeAction(this.statusSwipeAction()) - } - .width('100%') - .height(74) - .scrollBar(BarState.Off) - .divider(null) - } - - @Builder - DesktopStatusContent() { - Row() { - Text('●') - .fontSize(12) - .fontColor(this.statusDotColor()) - Column({ space: 6 }) { - Text(this.statusTitle()) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Text(this.statusDetail()) - .fontSize(12) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - if (this.remoteUrl.trim().length > 0) { - Text(this.desktopIdText()) - .fontSize(11) - .fontColor(SUBTLE) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - .margin({ left: 12 }) - if (this.isBusy) { - Blank() - Text('◌') - .fontSize(22) - .fontColor(INK) + ConnectManualPairingOverlay({ + remoteUrl: this.remoteUrl, + userIdInput: this.displayUserIdInput(), + password: this.accountPassword, + requiresAccountAuth: this.requiresAccountAuth, + canSubmit: this.canConnect(), + onRemoteUrlChange: this.onRemoteUrlChange, + onUserIdChange: this.onUserIdChange, + onPasswordChange: (value: string) => { this.accountPassword = value; }, + onCancel: () => this.closeManualPairing(), + onSubmit: () => { + this.ensureUserId(); + this.stopInlineScan(); + this.showManualPairing = false; + this.onConnect(this.accountPassword); } - } - .width('100%') - .height(74) - .backgroundColor(CARD) - .onClick(() => { - this.handleStatusClick(); }) } - @Builder - DeleteReveal() { - Text(RemoteI18n.t('connect.clear')) - .fontSize(13) - .fontColor(CARD) - .textAlign(TextAlign.Center) - .width(84) - .height(74) - .backgroundColor(RED) - .onClick(() => { - this.onClearPairing(); - }) - } - private statusDotColor(): ResourceColor { if (this.isConnected) { return GREEN; @@ -1002,13 +429,6 @@ export struct ConnectView { return SUBTLE; } - private statusTitle(): string { - if (this.remoteUrl.trim().length === 0) { - return RemoteI18n.t('connect.noDesktop'); - } - return this.desktopName || RemoteI18n.t('connect.targetDesktop'); - } - private statusDetail(): string { if (this.remoteUrl.trim().length === 0) { return RemoteI18n.t('connect.noDesktopDetail'); @@ -1028,13 +448,6 @@ export struct ConnectView { return RemoteI18n.t('connect.waitingDesktop'); } - private desktopIdText(): string { - if (this.desktopId.trim().length === 0) { - return RemoteI18n.t('connect.desktopIdUnavailable'); - } - return RemoteI18n.f('connect.desktopId', this.desktopId); - } - private displayUserIdInput(): string { if (this.requiresAccountAuth && this.accountUsername.length > 0 && this.userId.trim().length === 0) { return this.accountUsername; @@ -1045,24 +458,6 @@ export struct ConnectView { return this.userId; } - private statusSwipeAction(): SwipeActionOptions { - if (this.remoteUrl.trim().length === 0 || this.isBusy) { - return {}; - } - return { - end: { - builder: () => { - this.DeleteReveal(); - }, - actionAreaDistance: 84, - onAction: () => { - this.onClearPairing(); - } - }, - edgeEffect: SwipeEdgeEffect.None - }; - } - private handleStatusClick(): void { if (this.isBusy) { return; @@ -1093,6 +488,12 @@ export struct ConnectView { return this.displayUserIdInput().trim().length > 0 && this.accountPassword.length > 0; } + private closeManualPairing(): void { + this.stopInlineScan(); + this.showManualPairing = false; + this.resumeInlineScan(); + } + private currentStep(): string { if (this.pairingStep === 'account' && this.isAccountAuthenticated()) { return 'account'; @@ -1109,57 +510,6 @@ export struct ConnectView { return 'intro'; } - private async refreshAccountDevices(): Promise { - if (!this.isAccountAuthenticated() || this.accountDevicesBusy) return; - this.accountDevicesBusy = true; - this.accountDevicesError = ''; - try { - this.accountDevices = await this.cloudListDevices(); - } catch (err) { - this.accountDevicesError = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceLoadFailed'); - } finally { - this.accountDevicesBusy = false; - if (!this.hasOnlineDesktopDevice()) { - this.otherConnectionMethodsExpanded = true; - } - } - } - - private desktopDevices(): CloudAccountDevice[] { - return this.accountDevices.filter((device: CloudAccountDevice): boolean => - device.deviceId !== this.deviceId && device.deviceName !== 'HarmonyOS Phone'); - } - - private canSelectAccountDevice(device: CloudAccountDevice): boolean { - return device.online && device.deviceId !== this.deviceId && this.switchingDeviceId.length === 0; - } - - private hasOnlineDesktopDevice(): boolean { - const devices = this.desktopDevices(); - for (let index = 0; index < devices.length; index += 1) { - if (devices[index].online) { - return true; - } - } - return false; - } - - private accountDeviceStatus(device: CloudAccountDevice): string { - if (device.deviceId === this.switchingDeviceId) { - return RemoteI18n.t('remote.settings.deviceConnecting'); - } - const presence = device.online ? RemoteI18n.t('remote.settings.deviceOnline') : - RemoteI18n.t('remote.settings.deviceOffline'); - if (device.deviceId === this.controlTargetDeviceId && this.connectionState === 'connected') { - return `${RemoteI18n.t('remote.settings.deviceControlling')} · ${presence}`; - } - if (device.deviceId === this.controlTargetDeviceId) { - return `${RemoteI18n.t('connect.deviceLastUsed')} · ${presence}`; - } - return presence; - } - private isAccountAuthenticated(): boolean { return this.accountUserId.trim().length > 0; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets new file mode 100644 index 000000000..b8c759314 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets @@ -0,0 +1,58 @@ +import { LINE, SOFT } from './Theme'; + +@ComponentV2 +export struct ConversationLoadingState { + @Param maxContentWidth: number = 0; + + build() { + Row() { + Column({ space: 18 }) { + this.AssistantSkeleton(78, '72%') + this.UserSkeleton(42, '46%') + this.AssistantSkeleton(112, '84%') + } + .width('100%') + .constraintSize({ maxWidth: this.maxContentWidth > 0 ? this.maxContentWidth : '100%' }) + .padding({ left: 22, right: 22, top: 28, bottom: 28 }) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Top) + } + + @Builder + private AssistantSkeleton(height: number, width: string) { + Row() { + Column({ space: 9 }) { + Text('').width('74%').height(10).backgroundColor(LINE).borderRadius(5) + Text('').width('92%').height(10).backgroundColor(LINE).borderRadius(5) + Text('').width('58%').height(10).backgroundColor(LINE).borderRadius(5) + } + .width(width) + .height(height) + .padding({ left: 14, right: 14, top: 14, bottom: 14 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Start) + .backgroundColor(SOFT) + .borderRadius(10) + Blank().layoutWeight(1) + } + .width('100%') + .height(height) + } + + @Builder + private UserSkeleton(height: number, width: string) { + Row() { + Blank().layoutWeight(1) + Text('') + .width(width) + .height(height) + .backgroundColor(SOFT) + .borderRadius(10) + } + .width('100%') + .height(height) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets new file mode 100644 index 000000000..6a8256879 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets @@ -0,0 +1,94 @@ +import { ConversationIntent } from '../actions/ConversationIntent'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { FilePreviewPhase, FilePreviewState } from '../state/FilePreviewState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { RemotePageState } from '../state/RemotePageState'; +import { ComposerPresentation } from './ComposerBar'; +import { ConversationViewHost } from './ConversationViewHost'; +import { toConversationUiModelCatalog } from './ConversationUiModels'; +import { RemoteCreateSessionView } from './RemoteCreateSessionView'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; +import { ConversationViewState } from '../state/ConversationViewState'; +import { PAGE_BG } from './Theme'; + +@ComponentV2 +export struct ConversationRouteSurface { + @Param route: AppRoute = AppRoute.ChatHome; + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param filePreviewState: FilePreviewState = new FilePreviewState(); + @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Param showSidebarButton: boolean = true; + @Param showBackButton: boolean = false; + @Param showSidebarRestoreButton: boolean = false; + @Param useWidePresentation: boolean = false; + @Param contentHorizontalOffset: number = 0; + @Event onRestoreSidebar: () => void = () => {}; + + build() { + Column() { + if (this.route === AppRoute.RemoteHome) { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.CompactHome, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + onOpenSidebar: this.actions.onRemoteHome.openSidebar + }) + } else if (this.route === AppRoute.RemoteCreate) { + RemoteCreateSessionView({ + state: this.remoteCreateState, + presentation: this.useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Create, + isVoiceListening: this.remoteCreateState.isVoiceListening, + modelCatalog: toConversationUiModelCatalog(this.remotePageState.conversation.modelCatalog), + selectedModelId: this.remoteCreateState.selectedModelId, + showSidebarRestoreButton: this.showSidebarRestoreButton, + onRestoreSidebar: this.onRestoreSidebar, + onBack: this.actions.onRemoteCreate.back, + onToggleDeviceMenu: this.actions.onRemoteCreate.toggleDevices, + onToggleWorkspaceMenu: this.actions.onRemoteCreate.toggleWorkspaces, + onSelectDevice: this.actions.onRemoteCreate.selectDevice, + onSelectWorkspace: (workspace) => this.actions.onRemoteCreate.selectWorkspace(workspace?.path || ''), + onDraftChange: this.actions.onRemoteCreate.draftChanged, + onVoiceInput: this.actions.onRemoteCreate.voiceInput, + onSelectModel: this.actions.onRemoteCreate.selectModel, + onSend: this.actions.onRemoteCreate.send + }) + } else { + ConversationViewHost({ + viewState: ConversationViewState.project( + this.route, + this.remotePageState, + this.generalPageState, + this.actions.generalStatus() + ), + activeFilePreviewPath: this.route === AppRoute.RemoteChat && this.filePreviewState.visible ? + this.filePreviewState.target.remotePath : '', + activeFilePreviewLoading: this.route === AppRoute.RemoteChat && this.filePreviewState.visible && + this.filePreviewState.phase === FilePreviewPhase.Loading, + showSidebarButton: this.showSidebarButton, + showBackButton: this.showBackButton, + showSidebarRestoreButton: this.showSidebarRestoreButton, + composerPresentation: this.useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Compact, + contentHorizontalOffset: this.contentHorizontalOffset, + onRestoreSidebar: this.onRestoreSidebar, + onIntent: (intent: ConversationIntent) => this.actions.onConversationIntent(this.route, intent) + }) + } + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets index f940c851e..1c66b5a89 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets @@ -2,10 +2,10 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConversationSource } from '../navigation/AppRouteContract'; import { CARD, INK, LINE, MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct ConversationSourceSwitcher { - @Prop activeSource: ConversationSource = ConversationSource.General; - onSelectSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; + @Param activeSource: ConversationSource = ConversationSource.General; + @Event onSelectSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; build() { Row({ space: 2 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 6dbb9e269..db3f0fc7f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -6,6 +6,7 @@ import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from './C import { ChatSurface } from './ChatSurface'; import { ChatStatusBar } from './ChatStatusBar'; import { ChatTimeline } from './ChatTimeline'; +import { ConversationLoadingState } from './ConversationLoadingState'; import { ConversationViewContract } from './ConversationViewContract'; import { ConversationUiModelCatalog, @@ -34,6 +35,7 @@ export struct ConversationView { @Param connectionState: string = 'connected'; @Param composerCapabilities: ChatComposerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; @Param isBusy: boolean = false; + @Param isLoadingConversation: boolean = false; @Param canStop: boolean = false; @Param hasMoreMessages: boolean = false; @Param timelineItems: ChatTimelineItem[] = []; @@ -110,7 +112,12 @@ export struct ConversationView { if (this.shouldShowStatusBar()) { this.ExecutionStatusBar() } - if (this.shouldShowSuggestions()) { + if (this.isLoadingConversation) { + ConversationLoadingState({ + maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 800 : 0 + }) + .layoutWeight(1) + } else if (this.shouldShowSuggestions()) { Blank().layoutWeight(1) if (!this.isVoiceListening) { this.PromptArea() @@ -179,6 +186,7 @@ export struct ConversationView { workspaceBranch: this.workspaceBranch, desktopName: this.desktopName, showBackButton: this.showBackButton, + showSidebarButton: this.showSidebarButton, showSidebarRestoreButton: this.showSidebarRestoreButton, showActionsMenu: this.showHeaderActions, actionsMenu: () => { @@ -187,6 +195,9 @@ export struct ConversationView { onBack: () => { this.onBack(); }, + onOpenSidebar: () => { + this.onOpenSidebar(); + }, onRestoreSidebar: () => { this.onRestoreSidebar(); }, @@ -224,7 +235,7 @@ export struct ConversationView { timelineItems: this.visibleTimelineItems(), timelineRevision: this.timelineRevision, hasMoreMessages: this.hasMoreMessages, - isBusy: this.isBusy, + isBusy: this.isBusy || this.isLoadingConversation, connectionState: this.connectionState, statusText: this.statusText, downloadingFilePath: this.downloadingFilePath, @@ -675,7 +686,7 @@ export struct ConversationView { } private shouldShowStatusBar(): boolean { - return this.surface === ChatSurface.Remote && this.connectionState !== 'connected'; + return !this.isLoadingConversation && this.surface === ChatSurface.Remote && this.connectionState !== 'connected'; } private connectionColor(): ResourceColor { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets index 1c6bae841..045054b74 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets @@ -4,7 +4,7 @@ import { ConversationIntent, ConversationIntents, ConversationIntentType -} from './ConversationIntent'; +} from '../actions/ConversationIntent'; import { ConversationUiQuestionAnswer } from './ConversationUiModels'; import { ComposerPresentation } from './ComposerBar'; @@ -32,6 +32,7 @@ export struct ConversationViewHost { connectionState: this.viewState.connectionState, composerCapabilities: this.viewState.composerCapabilities, isBusy: this.viewState.isBusy, + isLoadingConversation: this.viewState.isLoadingConversation, canStop: this.viewState.canStop, hasMoreMessages: this.viewState.hasMoreMessages, timelineItems: this.viewState.timelineItems, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets index d965d5b08..f3e7176df 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets @@ -1,7 +1,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; import { RemoteLogger } from '../../services/RemoteLogger'; -import { ConversationSessionFilterPolicy } from '../state/ConversationSessionFilterPolicy'; +import { ConversationSessionFilterPolicy } from '../policy/ConversationSessionFilterPolicy'; import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; @ComponentV2 diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets index 7ad51ec5d..a00d0ca78 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets @@ -1,17 +1,19 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, SOFT, SUBTLE } from './Theme'; -@Component +@ComponentV2 export struct CreateSessionSheet { - @Prop createAgentType: string = 'code'; - @Prop workspaceName: string = ''; - @Prop workspaceBranch: string = ''; - @Prop isBusy: boolean = false; - @Link sessionTitle: string; - @Link instruction: string; - onClose: () => void = () => {}; - onChooseWorkspace: () => void = () => {}; - onStart: () => void = () => {}; + @Param createAgentType: string = 'code'; + @Param workspaceName: string = ''; + @Param workspaceBranch: string = ''; + @Param isBusy: boolean = false; + @Param sessionTitle: string = ''; + @Param instruction: string = ''; + @Event onSessionTitleChange: (value: string) => void = (_value: string) => {}; + @Event onInstructionChange: (value: string) => void = (_value: string) => {}; + @Event onClose: () => void = () => {}; + @Event onChooseWorkspace: () => void = () => {}; + @Event onStart: () => void = () => {}; build() { Column() { @@ -123,7 +125,7 @@ export struct CreateSessionSheet { .border({ width: 1, color: LINE }) .defaultFocus(false) .onChange((value: string) => { - this.sessionTitle = value; + this.onSessionTitleChange(value); }) } .width('100%') @@ -146,7 +148,7 @@ export struct CreateSessionSheet { .border({ width: 1, color: LINE }) .defaultFocus(false) .onChange((value: string) => { - this.instruction = value; + this.onInstructionChange(value); }) } .width('100%') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets index 72fb3c48c..a4f939b8c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets @@ -1,8 +1,8 @@ import { MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct DefaultAccountAvatar { - @Prop avatarSize: number = 34; + @Param avatarSize: number = 34; build() { Stack({ alignContent: Alignment.Center }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets index 06f477b89..139edbac8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets @@ -1,17 +1,17 @@ import { CARD, FILE_LINK, INK, LINE, MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct FileReferenceCard { - @Prop path: string = ''; - @Prop label: string = ''; - @Prop status: string = ''; - @Prop previewLabel: string = ''; - @Prop buttonLabel: string = ''; - @Prop disabled: boolean = false; - @Prop selected: boolean = false; - @Prop previewLoading: boolean = false; - onPreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; - onDownload: (path: string) => void = (_path: string) => {}; + @Param path: string = ''; + @Param label: string = ''; + @Param status: string = ''; + @Param previewLabel: string = ''; + @Param buttonLabel: string = ''; + @Param disabled: boolean = false; + @Param selected: boolean = false; + @Param previewLoading: boolean = false; + @Event onPreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; + @Event onDownload: (path: string) => void = (_path: string) => {}; build() { Row({ space: 10 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets index a088f54d8..0382f2db6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets @@ -1,11 +1,13 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK, LINE, PAGE_BG } from './Theme'; +import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { CompactMenuButton } from './CompactMenuButton'; import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 export struct GeneralChatHeader { @Param title: string = ''; + /** Secondary context line. Empty keeps the single-line header. */ + @Param subtitle: string = ''; @Param showActions: boolean = false; @Param showSidebarButton: boolean = true; @Param showBackButton: boolean = false; @@ -21,23 +23,42 @@ export struct GeneralChatHeader { build() { Row({ space: 8 }) { this.LeadingControl() + this.TitleBlock() + this.TrailingControl() + } + .width('100%') + .height(this.hasSubtitle() ? 76 : 64) + .alignItems(VerticalAlign.Center) + .padding({ left: 16, right: 16, top: 8, bottom: 8 }) + .backgroundColor(PAGE_BG) + } + /** Mirrors the conversation header: title above a muted context line. */ + @Builder + private TitleBlock() { + Column({ space: 3 }) { Text(this.title || 'BitFun') - .fontSize(17) + .fontSize(this.hasSubtitle() ? 18 : 17) .fontWeight(FontWeight.Medium) .fontColor(INK) - .layoutWeight(1) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .textAlign(TextAlign.Center) - - this.TrailingControl() + if (this.hasSubtitle()) { + Text(this.subtitle) + .fontSize(14) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .textAlign(TextAlign.Center) + } } - .width('100%') - .height(64) - .alignItems(VerticalAlign.Center) - .padding({ left: 16, right: 16, top: 8, bottom: 8 }) - .backgroundColor(PAGE_BG) + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + + private hasSubtitle(): boolean { + return this.subtitle.length > 0; } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets index 486aad6a6..5b7e8d76a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets @@ -7,12 +7,12 @@ import { } from '../../services/MarkdownParser'; import { CARD, FILE_LINK, INK, LINE, MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct MarkdownContent { private readonly parseCache: MarkdownParseCache = new MarkdownParseCache(); - @Prop text: string = ''; - onCopyText: (text: string) => void = (_text: string) => {}; - onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; + @Param text: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; build() { Column({ space: 5 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets index 3c92d1915..07996fbb8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets @@ -4,24 +4,24 @@ import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels' import { GENERAL_CHAT_LOCAL_MODEL_ID } from '../../services/general-chat/GeneralChatConfigStore'; import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT, SUBTLE } from './Theme'; -@Component +@ComponentV2 export struct ModelServiceSettingsPanel { private readonly contentScroller: Scroller = new Scroller(); private focusScrollTimerId: number = 0; private blurResetTimerId: number = 0; private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; - @Prop apiUrl: string = ''; - @Prop modelName: string = ''; - @Prop hasApiKey: boolean = false; - @Prop modelCatalog: RemoteModelCatalog = { + @Param apiUrl: string = ''; + @Param modelName: string = ''; + @Param hasApiKey: boolean = false; + @Param modelCatalog: RemoteModelCatalog = { version: 0, models: [], default_models: {} }; - @Prop selectedModelId: string = ''; - onClose: () => void = () => {}; - onSaved: (apiUrl: string, modelName: string, hasApiKey: boolean) => void = () => {}; - onTest: ( + @Param selectedModelId: string = ''; + @Event onClose: () => void = () => {}; + @Event onSaved: (apiUrl: string, modelName: string, hasApiKey: boolean) => void = () => {}; + @Event onTest: ( apiUrl: string, apiKey: string, modelName: string, @@ -32,7 +32,7 @@ export struct ModelServiceSettingsPanel { _modelName: string, _clearApiKey: boolean ) => ''; - onSave: ( + @Event onSave: ( apiUrl: string, apiKey: string, modelName: string, @@ -43,16 +43,16 @@ export struct ModelServiceSettingsPanel { _modelName: string, _clearApiKey: boolean ) => ''; - @State draftApiUrl: string = ''; - @State draftApiKey: string = ''; - @State draftModelName: string = ''; - @State clearApiKey: boolean = false; - @State isSaving: boolean = false; - @State isTesting: boolean = false; - @State feedbackText: string = ''; - @State feedbackIsError: boolean = false; - @State focusedFieldKind: string = ''; - @State showLocalEditor: boolean = false; + @Local draftApiUrl: string = ''; + @Local draftApiKey: string = ''; + @Local draftModelName: string = ''; + @Local clearApiKey: boolean = false; + @Local isSaving: boolean = false; + @Local isTesting: boolean = false; + @Local feedbackText: string = ''; + @Local feedbackIsError: boolean = false; + @Local focusedFieldKind: string = ''; + @Local showLocalEditor: boolean = false; aboutToAppear(): void { this.previousKeyboardAvoidMode = this.getUIContext().getKeyboardAvoidMode(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets index 67b5b3813..539ea93aa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets @@ -1,6 +1,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConversationUiSession } from './ConversationUiModels'; import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION_TEXT, SOFT } from './Theme'; +import { CompactMenuButton } from './CompactMenuButton'; import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 @@ -14,10 +15,12 @@ export struct RemoteChatHeader { @Param workspaceBranch: string = ''; @Param desktopName: string = ''; @Param showBackButton: boolean = true; + @Param showSidebarButton: boolean = false; @Param showSidebarRestoreButton: boolean = false; @Param showActionsMenu: boolean = false; @BuilderParam actionsMenu: () => void = this.EmptyBuilder; @Event onBack: () => void = () => {}; + @Event onOpenSidebar: () => void = () => {}; @Event onRestoreSidebar: () => void = () => {}; @Event onOpenActions: () => void = () => {}; @Event onActionsMenuStateChange: (visible: boolean) => void = (_visible: boolean) => {}; @@ -100,6 +103,13 @@ export struct RemoteChatHeader { .onClick(() => { this.onBack(); }) + } else if (this.showSidebarButton) { + CompactMenuButton({ + controlSize: 44, + onOpen: () => { + this.onOpenSidebar(); + } + }) } else { Blank().width(44).height(44) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets index 9ed424531..3396da0e9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets @@ -5,45 +5,45 @@ import { RemotePermissionMode } from '../../model/RemoteModels'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; import { BitFunAccountLoginPage } from './BitFunAccountLoginPage'; -@Component +@ComponentV2 export struct RemoteControlSettingsSheet { - @Prop desktopName: string = ''; - @Prop desktopId: string = ''; - @Prop userId: string = ''; - @Prop accountUsername: string = ''; - @Prop @Watch('handleAccountUserChanged') accountUserId: string = ''; - @Prop deviceId: string = ''; - @Prop controlTargetType: string = 'none'; - @Prop controlTargetDeviceId: string = ''; - @Prop connectionState: string = 'idle'; - @Prop statusText: string = ''; - @Prop isBusy: boolean = false; - @Prop openAccountOnAppear: boolean = false; - onClose: () => void = () => {}; - onOpenAccount: () => void = () => {}; - onAddConnection: () => void = () => {}; - cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; - cloudSync: () => Promise = async (): Promise => '0'; - cloudLogout: () => Promise = async (): Promise => {}; - cloudListDevices: () => Promise = async (): Promise => []; - getPermissionMode: () => Promise = async (): Promise => 'ask'; - setPermissionMode: (mode: RemotePermissionMode) => Promise = + @Param desktopName: string = ''; + @Param desktopId: string = ''; + @Param userId: string = ''; + @Param accountUsername: string = ''; + @Param accountUserId: string = ''; + @Param deviceId: string = ''; + @Param controlTargetType: string = 'none'; + @Param controlTargetDeviceId: string = ''; + @Param connectionState: string = 'idle'; + @Param statusText: string = ''; + @Param isBusy: boolean = false; + @Param openAccountOnAppear: boolean = false; + @Event onClose: () => void = () => {}; + @Event onOpenAccount: () => void = () => {}; + @Event onAddConnection: () => void = () => {}; + @Event cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; + @Event cloudSync: () => Promise = async (): Promise => '0'; + @Event cloudLogout: () => Promise = async (): Promise => {}; + @Event cloudListDevices: () => Promise = async (): Promise => []; + @Event getPermissionMode: () => Promise = async (): Promise => 'ask'; + @Event setPermissionMode: (mode: RemotePermissionMode) => Promise = async (mode: RemotePermissionMode): Promise => mode; - onDisconnect: () => void = () => {}; - onReconnect: () => void = () => {}; - @State showProfile: boolean = false; - @State showLogin: boolean = false; - @State cloudSyncBusy: boolean = false; - @State cloudSyncStatus: string = ''; - @State accountDevices: CloudAccountDevice[] = []; - @State accountDevicesBusy: boolean = false; - @State accountDevicesError: string = ''; - @State permissionMode: RemotePermissionMode = 'ask'; - @State permissionModeBusy: boolean = false; - @State permissionModeLoaded: boolean = false; - @State permissionModeError: string = ''; - @State confirmFullAccess: boolean = false; - @State logoutBusy: boolean = false; + @Event onDisconnect: () => void = () => {}; + @Event onReconnect: () => void = () => {}; + @Local showProfile: boolean = false; + @Local showLogin: boolean = false; + @Local cloudSyncBusy: boolean = false; + @Local cloudSyncStatus: string = ''; + @Local accountDevices: CloudAccountDevice[] = []; + @Local accountDevicesBusy: boolean = false; + @Local accountDevicesError: string = ''; + @Local permissionMode: RemotePermissionMode = 'ask'; + @Local permissionModeBusy: boolean = false; + @Local permissionModeLoaded: boolean = false; + @Local permissionModeError: string = ''; + @Local confirmFullAccess: boolean = false; + @Local logoutBusy: boolean = false; aboutToAppear(): void { this.showProfile = this.openAccountOnAppear && this.isAccountAuthenticated(); @@ -855,6 +855,7 @@ export struct RemoteControlSettingsSheet { return this.accountUserId.trim().length > 0; } + @Monitor('accountUserId') private handleAccountUserChanged(): void { if (this.isAccountAuthenticated() && this.accountDevices.length === 0) { this.refreshAccountDevices(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets index d1d6b2d8c..bfca15020 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets @@ -3,9 +3,9 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { TimeFormat } from '../../services/TimeFormat'; import { CARD, INK, MUTED, SOFT } from './Theme'; import { SessionActionPresentation, SessionActionSurface } from './SessionActionSurface'; -import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../state/SessionActionPolicy'; +import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../policy/SessionActionPolicy'; import { SessionDetailsView } from './SessionDetailsView'; -import { ConversationSessionFilterPolicy } from '../state/ConversationSessionFilterPolicy'; +import { ConversationSessionFilterPolicy } from '../policy/ConversationSessionFilterPolicy'; @ComponentV2 export struct RemoteSessionList { @@ -46,12 +46,18 @@ export struct RemoteSessionList { @Local showSessionActionSheet: boolean = false; @Local detailsSessionId: string = ''; @Local showSessionDetails: boolean = false; + @Local optimisticSelectedSessionId: string = ''; @Monitor('isBusy', 'workspacePath') onWorkspaceContextChanged(): void { this.createMenuPath = ''; } + @Monitor('selectedSessionId') + onSelectedSessionChanged(): void { + this.optimisticSelectedSessionId = ''; + } + build() { Column() { Scroll() { @@ -566,7 +572,7 @@ export struct RemoteSessionList { Text(item.title || RemoteI18n.t('sidebar.untitled')) .width('100%') .fontSize(15) - .fontWeight(this.selectedSessionId === item.id ? FontWeight.Medium : FontWeight.Regular) + .fontWeight(this.isSessionSelected(item.id) ? FontWeight.Medium : FontWeight.Regular) .fontColor(INK) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) @@ -587,9 +593,23 @@ export struct RemoteSessionList { .height(this.metadataText(item).length > 0 ? 56 : 46) .padding({ left: nested ? 0 : 10, right: 4 }) .alignItems(VerticalAlign.Center) - .backgroundColor(this.selectedSessionId === item.id ? SOFT : '#00000000') + .backgroundColor(this.isSessionSelected(item.id) ? SOFT : '#00000000') .borderRadius(10) + .onTouch((event: TouchEvent) => { + if (this.isBusy) { + return; + } + if (event.type === TouchType.Down) { + this.optimisticSelectedSessionId = item.id; + } else if (event.type === TouchType.Cancel) { + this.optimisticSelectedSessionId = ''; + } + }) .onClick(() => { + if (this.isBusy) { + return; + } + this.optimisticSelectedSessionId = item.id; this.onOpenSession(item); }) .gesture(LongPressGesture({ repeat: false }).onAction(() => this.openSessionActions(item))) @@ -610,6 +630,12 @@ export struct RemoteSessionList { }) } + private isSessionSelected(sessionId: string): boolean { + const selectedSessionId = this.optimisticSelectedSessionId.length > 0 ? + this.optimisticSelectedSessionId : this.selectedSessionId; + return selectedSessionId === sessionId; + } + @Builder private SessionMoreButton(item: RemoteSession) { Stack({ alignContent: Alignment.Center }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index a90edecec..0a3cb71b9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -4,23 +4,23 @@ import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { ModelServiceSettingsPanel } from './ModelServiceSettingsPanel'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; -@Component +@ComponentV2 export struct SettingsSheet { - @Prop generalChatApiUrl: string = ''; - @Prop generalChatModelName: string = ''; - @Prop hasGeneralChatApiKey: boolean = false; - @Prop generalChatModelCatalog: RemoteModelCatalog = { + @Param generalChatApiUrl: string = ''; + @Param generalChatModelName: string = ''; + @Param hasGeneralChatApiKey: boolean = false; + @Param generalChatModelCatalog: RemoteModelCatalog = { version: 0, models: [], default_models: {} }; - @Prop selectedGeneralChatModelId: string = ''; - @Prop accountUsername: string = ''; - @Prop authenticatedUserId: string = ''; - @Prop deviceId: string = ''; - onClose: () => void = () => {}; - onOpenAccount: () => void = () => {}; - onSaveGeneralChatConfig: ( + @Param selectedGeneralChatModelId: string = ''; + @Param accountUsername: string = ''; + @Param authenticatedUserId: string = ''; + @Param deviceId: string = ''; + @Event onClose: () => void = () => {}; + @Event onOpenAccount: () => void = () => {}; + @Event onSaveGeneralChatConfig: ( apiUrl: string, apiKey: string, modelName: string, @@ -31,7 +31,7 @@ export struct SettingsSheet { _modelName: string, _clearApiKey: boolean ) => ''; - onTestGeneralChatConfig: ( + @Event onTestGeneralChatConfig: ( apiUrl: string, apiKey: string, modelName: string, @@ -42,10 +42,10 @@ export struct SettingsSheet { _modelName: string, _clearApiKey: boolean ) => ''; - @State showModelService: boolean = false; - @State savedGeneralChatApiUrl: string = ''; - @State savedGeneralChatModelName: string = ''; - @State savedGeneralChatHasApiKey: boolean = false; + @Local showModelService: boolean = false; + @Local savedGeneralChatApiUrl: string = ''; + @Local savedGeneralChatModelName: string = ''; + @Local savedGeneralChatHasApiKey: boolean = false; aboutToAppear(): void { this.showModelService = false; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets new file mode 100644 index 000000000..af5a2504d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets @@ -0,0 +1,151 @@ +import { CARD, GREEN, INK, MUTED } from './Theme'; + +@ComponentV2 +export struct SidebarGlyph { + @Param kind: string = ''; + @Param connectionState: string = ''; + + build() { + if (this.kind === 'session_more') { + this.MoreDots() + } else if (this.kind === 'remote') { + this.Remote() + } else if (this.kind === 'search') { + this.Search() + } else if (this.kind === 'notebook') { + this.Notebook() + } else if (this.kind === 'clock') { + this.Clock() + } else if (this.kind === 'apps') { + this.Apps() + } else if (this.kind === 'code_flower') { + this.CodeFlower() + } else if (this.kind === 'more') { + this.More() + } else if (this.kind === 'edit') { + this.Edit() + } else if (this.kind === 'settings') { + this.Settings() + } + } + + @Builder + private MoreDots() { + Row({ space: 3 }) { + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + } + .height(8) + .alignItems(VerticalAlign.Center) + } + + @Builder + private Remote() { + Stack({ alignContent: Alignment.Center }) { + if (this.connectionState === 'connected' || this.connectionState === 'reconnecting') { + Image($r('app.media.remote_ref_sidebar_connected')) + .width(35).height(34).objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template).foregroundColor(INK) + Text('').width(8).height(8).backgroundColor(GREEN).borderRadius(4) + .position({ x: 24, y: 22 }) + } else { + Image($r('app.media.remote_logo')) + .width(34).height(34).objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template).foregroundColor(MUTED) + } + } + .width(35).height(34) + } + + @Builder + private Search() { + SymbolGlyph($r('sys.symbol.magnifyingglass')) + .fontSize(22).fontColor([INK]).width(24).height(24) + } + + @Builder + private Notebook() { + Stack() { + Text('').width(22).height(24).borderRadius(5).border({ width: 1.5, color: INK }) + .position({ x: 8, y: 5 }) + Text('').width(4).height(4).borderRadius(2).backgroundColor(INK) + .position({ x: 5, y: 11 }) + Text('').width(4).height(4).borderRadius(2).backgroundColor(INK) + .position({ x: 5, y: 20 }) + } + .width(34).height(34) + } + + @Builder + private Clock() { + Stack() { + Text('').width(26).height(26).borderRadius(13).border({ width: 1.5, color: INK }) + .position({ x: 4, y: 4 }) + Text('').width(1.5).height(9).backgroundColor(INK).borderRadius(2) + .position({ x: 18, y: 10 }) + Text('').width(9).height(1.5).backgroundColor(INK).borderRadius(2) + .position({ x: 18, y: 20 }) + } + .width(34).height(34) + } + + @Builder + private Apps() { + Column({ space: 8 }) { + Row({ space: 8 }) { this.AppDot(); this.AppDot(); } + Row({ space: 8 }) { this.AppDot(); this.AppDot(); } + } + .width(24).height(24) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + private AppDot() { + Text('').width(8).height(8).borderRadius(4).backgroundColor(INK) + } + + @Builder + private CodeFlower() { + Stack() { + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 9, y: 1 }) + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 17, y: 9 }) + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 9, y: 17 }) + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 1, y: 9 }) + Text('').width(14).height(14).borderRadius(7).backgroundColor(CARD) + .position({ x: 10, y: 10 }) + } + .width(34).height(34) + } + + @Builder + private More() { + Row({ space: 5 }) { this.Dot(); this.Dot(); this.Dot(); } + .width(30).height(22) + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Center) + } + + @Builder + private Dot() { + Text('').width(5).height(5).borderRadius(3).backgroundColor(INK) + } + + @Builder + private Edit() { + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(22).fontColor([INK]).width(24).height(24) + } + + @Builder + private Settings() { + SymbolGlyph($r('sys.symbol.gearshape')) + .fontSize(22).fontColor([INK]).width(24).height(24) + } +} + diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets index 227cccd2a..283e2698a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets @@ -2,14 +2,14 @@ import { MarkdownContent } from './MarkdownContent'; const STREAMING_MARKDOWN_CACHE: Map = new Map(); -@Component +@ComponentV2 export struct StreamingMarkdownContent { - @Prop @Watch('handleTextChanged') text: string = ''; - @Prop @Watch('handleTextChanged') active: boolean = false; - @Prop @Watch('handleTextChanged') streamKey: string = ''; - onCopyText: (text: string) => void = (_text: string) => {}; - onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; - @State renderedText: string = ''; + @Param text: string = ''; + @Param active: boolean = false; + @Param streamKey: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; + @Local renderedText: string = ''; private targetText: string = ''; private timerId: number = 0; private frameIntervalMs: number = 40; @@ -42,6 +42,7 @@ export struct StreamingMarkdownContent { }) } + @Monitor('text', 'active', 'streamKey') private handleTextChanged(): void { if (!this.active) { this.clearTimer(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets index 6adc633b7..854c234a4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets @@ -12,6 +12,8 @@ export const CONNECT_HERO_BG: ResourceColor = $r('app.color.connect_hero_bg'); export const CONNECT_HERO_ACCENT: ResourceColor = $r('app.color.connect_hero_accent'); export const CONNECT_HERO_SECONDARY: ResourceColor = $r('app.color.connect_hero_secondary'); export const CONNECT_HERO_SURFACE: ResourceColor = $r('app.color.connect_hero_surface'); +export const CONNECT_SCAN_ACCENT: ResourceColor = $r('app.color.connect_scan_accent'); +export const MODAL_SCRIM: ResourceColor = $r('app.color.modal_scrim'); export const SOFT: ResourceColor = $r('app.color.soft'); export const FLOATING_PANEL_BG: ResourceColor = $r('app.color.floating_panel_bg'); export const GREEN: ResourceColor = $r('app.color.green'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets index 89bacf242..42a9a75af 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets @@ -1,14 +1,14 @@ import { MUTED } from './Theme'; -@Component +@ComponentV2 export struct ThinkingBlock { - @Prop text: string = ''; - @Prop status: string = ''; - @Prop keepExpandedWhenDone: boolean = false; - @Prop streaming: boolean = false; - @Prop streamKey: string = ''; - onCopyText: (text: string) => void = (_text: string) => {}; - @State dotPhase: number = 0; + @Param text: string = ''; + @Param status: string = ''; + @Param keepExpandedWhenDone: boolean = false; + @Param streaming: boolean = false; + @Param streamKey: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Local dotPhase: number = 0; private dotTimerId: number = 0; aboutToAppear(): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets new file mode 100644 index 000000000..cc029ab41 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets @@ -0,0 +1,40 @@ +import { MUTED } from './Theme'; + +@ComponentV2 +export struct ToolGlyph { + @Param kind: string = 'tool'; + @Param color: ResourceColor = MUTED; + + build() { + SymbolGlyph(this.symbol()) + .fontSize(this.isChevron() ? 18 : 14) + .fontColor([this.color]) + .width(this.isChevron() ? 14 : 15) + .height(this.isChevron() ? 14 : 15) + } + + private isChevron(): boolean { + return this.kind.indexOf('chevron_') === 0; + } + + private symbol(): Resource { + if (this.kind === 'search') return $r('sys.symbol.magnifyingglass'); + if (this.kind === 'document') return $r('sys.symbol.doc_text'); + if (this.kind === 'stack') return $r('sys.symbol.rectangle_stack'); + if (this.kind === 'question') return $r('sys.symbol.questionmark_circle'); + if (this.kind === 'todo') return $r('sys.symbol.list_checkmark'); + if (this.kind === 'task') return $r('sys.symbol.robot'); + if (this.kind === 'git') return $r('sys.symbol.arrow_triangle_merge'); + if (this.kind === 'delete') return $r('sys.symbol.trash'); + if (this.kind === 'diff') return $r('sys.symbol.doc_text_badge_magnifyingglass'); + if (this.kind === 'patch' || this.kind === 'command') return $r('sys.symbol.code_square'); + if (this.kind === 'create') return $r('sys.symbol.doc_text_badge_arrow_up'); + if (this.kind === 'mutate') return $r('sys.symbol.square_and_pencil'); + if (this.kind === 'folder') return $r('sys.symbol.folder'); + if (this.kind === 'web') return $r('sys.symbol.link'); + if (this.kind === 'chevron_right') return $r('sys.symbol.chevron_right'); + if (this.kind === 'chevron_up') return $r('sys.symbol.chevron_up'); + if (this.kind === 'chevron_down') return $r('sys.symbol.chevron_down'); + return $r('sys.symbol.wrench_and_screwdriver'); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets new file mode 100644 index 000000000..9af587e2a --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets @@ -0,0 +1,171 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConversationUiQuestionAnswer } from './ConversationUiModels'; +import { ACCENT, CARD, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; + +@ComponentV2 +export struct ToolConfirmationPanel { + @Param toolId: string = ''; + @Param defaultInputText: string = ''; + @Param hasEditableInput: boolean = false; + @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = + (_toolId: string, _updatedInput?: Object) => {}; + @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; + @Local inputText: string = ''; + @Local inputError: string = ''; + + aboutToAppear(): void { + this.inputText = this.defaultInputText; + } + + build() { + Column({ space: 8 }) { + if (this.hasEditableInput) { + this.InputEditor() + } + Row({ space: 8 }) { + Text(RemoteI18n.t('chat.approve')) + .fontSize(12) + .fontColor(PRIMARY_ACTION_TEXT) + .textAlign(TextAlign.Center) + .height(32) + .layoutWeight(1) + .backgroundColor(ACCENT) + .borderRadius(16) + .onClick(() => this.approve()) + Text(RemoteI18n.t('chat.reject')) + .fontSize(12) + .fontColor(INK) + .textAlign(TextAlign.Center) + .height(32) + .layoutWeight(1) + .backgroundColor(SOFT) + .borderRadius(16) + .border({ width: 1, color: LINE }) + .onClick(() => this.onRejectTool(this.toolId)) + } + .width('100%') + } + .width('100%') + .padding({ left: 30 }) + } + + @Builder + private InputEditor() { + Column({ space: 6 }) { + Row() { + Text(RemoteI18n.t('chat.toolInput')).fontSize(11).fontColor(MUTED) + Blank() + Text(RemoteI18n.t('chat.reset')) + .fontSize(11) + .fontColor(MUTED) + .onClick(() => { + this.inputText = this.defaultInputText; + this.inputError = ''; + }) + } + .width('100%') + TextArea({ placeholder: RemoteI18n.t('chat.editJsonInput'), text: this.inputText }) + .height(96) + .fontSize(12) + .fontColor(INK) + .lineHeight(17) + .backgroundColor(SOFT) + .borderRadius(14) + .padding(10) + .border({ width: 1, color: this.inputError.length > 0 ? RED : LINE }) + .defaultFocus(false) + .enabled(true) + .onChange((value: string) => { + this.inputText = value; + this.inputError = ''; + }) + if (this.inputError.length > 0) { + Text(this.inputError).fontSize(11).fontColor(RED) + } + } + .width('100%') + } + + private approve(): void { + if (this.toolId.length === 0) { + return; + } + if (!this.hasEditableInput) { + this.onApproveTool(this.toolId); + return; + } + const rawInput = this.inputText.trim(); + if (rawInput.length === 0) { + this.inputError = RemoteI18n.t('chat.jsonObjectRequired'); + return; + } + try { + const parsed = JSON.parse(rawInput) as Object; + if (parsed === null || Array.isArray(parsed)) { + this.inputError = RemoteI18n.t('chat.jsonObjectRequired'); + return; + } + this.inputError = ''; + this.onApproveTool(this.toolId, parsed); + } catch (_err) { + this.inputError = RemoteI18n.t('chat.jsonInvalid'); + } + } +} + +@ComponentV2 +export struct ToolQuestionAnswerPanel { + @Param toolId: string = ''; + @Param prompt: string = ''; + @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = + (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Local answerText: string = ''; + + build() { + Column({ space: 8 }) { + Text(this.prompt) + .fontSize(12) + .lineHeight(17) + .fontColor(INK) + .width('100%') + TextArea({ placeholder: RemoteI18n.t('chat.answerPlaceholder'), text: this.answerText }) + .height(78) + .fontSize(13) + .backgroundColor(CARD) + .borderRadius(14) + .padding(12) + .border({ width: 1, color: LINE }) + .defaultFocus(false) + .enabled(true) + .onChange((value: string) => { this.answerText = value; }) + Row() { + Text(RemoteI18n.t('chat.submitAnswer')) + .fontSize(12) + .fontColor(this.canSubmit() ? PRIMARY_ACTION_TEXT : MUTED) + .textAlign(TextAlign.Center) + .height(32) + .layoutWeight(1) + .backgroundColor(this.canSubmit() ? ACCENT : SOFT) + .borderRadius(16) + .onClick(() => this.submit()) + } + .width('100%') + } + .width('100%') + .padding({ left: 30 }) + } + + private canSubmit(): boolean { + return this.toolId.length > 0 && this.answerText.trim().length > 0; + } + + private submit(): void { + if (!this.canSubmit()) { + return; + } + const answer = this.answerText.trim(); + const answers: ConversationUiQuestionAnswer = { answer, '0': answer }; + this.onAnswerQuestion(this.toolId, answers); + this.answerText = ''; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets index bd1316fb2..bb8c2ed7e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets @@ -1,7 +1,9 @@ import { ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ToolFileReference, ToolFileReferenceResolver } from '../../services/ToolFileReferenceResolver'; -import { ACCENT, CARD, FILE_LINK, GREEN, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; +import { CARD, FILE_LINK, GREEN, INK, LINE, MUTED, RED, SOFT } from './Theme'; +import { ToolGlyph } from './ToolGlyphs'; +import { ToolConfirmationPanel, ToolQuestionAnswerPanel } from './ToolInteractionPanels'; interface QuestionPreview { header?: string; @@ -61,11 +63,6 @@ export struct ToolStatusList { (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; - @Local questionAnswerToolId: string = ''; - @Local questionAnswerText: string = ''; - @Local toolInputEditToolId: string = ''; - @Local toolInputEditText: string = ''; - @Local toolInputEditError: string = ''; @Local expanded: boolean = false; @Local expandedToolKey: string = ''; @@ -138,39 +135,20 @@ export struct ToolStatusList { } if (this.isPendingConfirmation(tool)) { - if (this.hasEditableToolInput(tool)) { - this.ToolInputEditor(tool) - } - Row({ space: 8 }) { - Text(RemoteI18n.t('chat.approve')) - .fontSize(12) - .fontColor(PRIMARY_ACTION_TEXT) - .textAlign(TextAlign.Center) - .height(32) - .layoutWeight(1) - .backgroundColor(ACCENT) - .borderRadius(16) - .onClick(() => { - this.approveToolWithInput(tool); - }) - Text(RemoteI18n.t('chat.reject')) - .fontSize(12) - .fontColor(INK) - .textAlign(TextAlign.Center) - .height(32) - .layoutWeight(1) - .backgroundColor(SOFT) - .borderRadius(16) - .border({ width: 1, color: LINE }) - .onClick(() => { - this.onRejectTool(tool.id || ''); - }) - } - .width('100%') - .padding({ left: 30 }) + ToolConfirmationPanel({ + toolId: tool.id || '', + defaultInputText: this.defaultToolInputText(tool), + hasEditableInput: this.hasEditableToolInput(tool), + onApproveTool: this.onApproveTool, + onRejectTool: this.onRejectTool + }) } if (this.isQuestionTool(tool)) { - this.QuestionAnswer(tool) + ToolQuestionAnswerPanel({ + toolId: tool.id || '', + prompt: this.questionPrompt(tool), + onAnswerQuestion: this.onAnswerQuestion + }) } if (this.isRunningTool(tool)) { Row() { @@ -260,122 +238,12 @@ export struct ToolStatusList { @Builder SummaryTypeSymbol(entry: ToolRenderEntry) { - if (entry.searchCount > 0 && entry.readCount === 0) { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(14) - .fontColor([this.summaryTypeColor(entry)]) - .width(15) - .height(15) - } else if (entry.readCount > 0 && entry.searchCount === 0) { - SymbolGlyph($r('sys.symbol.doc_text')) - .fontSize(14) - .fontColor([this.summaryTypeColor(entry)]) - .width(15) - .height(15) - } else { - SymbolGlyph($r('sys.symbol.rectangle_stack')) - .fontSize(14) - .fontColor([this.summaryTypeColor(entry)]) - .width(15) - .height(15) - } + ToolGlyph({ kind: this.summaryGlyphKind(entry), color: this.summaryTypeColor(entry) }) } @Builder ToolTypeSymbol(tool: ConversationUiToolStatus) { - if (this.isQuestionLikeTool(tool)) { - SymbolGlyph($r('sys.symbol.questionmark_circle')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isTodoTool(tool)) { - SymbolGlyph($r('sys.symbol.list_checkmark')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isTaskTool(tool)) { - SymbolGlyph($r('sys.symbol.robot')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isGitTool(tool)) { - SymbolGlyph($r('sys.symbol.arrow_triangle_merge')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isDeleteTool(tool)) { - SymbolGlyph($r('sys.symbol.trash')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isDiffTool(tool)) { - SymbolGlyph($r('sys.symbol.doc_text_badge_magnifyingglass')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isPatchTool(tool)) { - SymbolGlyph($r('sys.symbol.code_square')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isFileCreateTool(tool)) { - SymbolGlyph($r('sys.symbol.doc_text_badge_arrow_up')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isFileMutationTool(tool)) { - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isFileReadTool(tool)) { - if (this.isDirectoryListTool(tool)) { - SymbolGlyph($r('sys.symbol.folder')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else { - SymbolGlyph($r('sys.symbol.doc_text')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } - } else if (this.isSearchTool(tool)) { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isWebTool(tool)) { - SymbolGlyph($r('sys.symbol.link')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isCommandTool(tool)) { - SymbolGlyph($r('sys.symbol.code_square')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else { - SymbolGlyph($r('sys.symbol.wrench_and_screwdriver')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } + ToolGlyph({ kind: this.toolGlyphKind(tool), color: this.toolTypeColor(tool) }) } @Builder @@ -392,77 +260,9 @@ export struct ToolStatusList { .border({ width: 1, color: CARD }) } - @Builder - RunningDotsIcon() { - Row({ space: 2 }) { - Text('') - .width(3.5) - .height(3.5) - .borderRadius(2) - .backgroundColor(ACCENT) - Text('') - .width(3.5) - .height(3.5) - .borderRadius(2) - .backgroundColor(ACCENT) - Text('') - .width(3.5) - .height(3.5) - .borderRadius(2) - .backgroundColor(ACCENT) - } - .width(16) - .height(16) - .justifyContent(FlexAlign.Center) - } - - @Builder - AlertCircleIcon(color: string, mark: string) { - Text(mark) - .width(16) - .height(16) - .fontSize(10) - .fontColor(color) - .textAlign(TextAlign.Center) - .border({ width: 1.5, color }) - .borderRadius(8) - } - - @Builder - NeutralDotIcon() { - Stack() { - Text('') - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor(MUTED) - .position({ x: 6, y: 6 }) - } - .width(16) - .height(16) - } - @Builder ChevronIcon(direction: string) { - if (direction === 'right') { - SymbolGlyph($r('sys.symbol.chevron_right')) - .fontSize(18) - .fontColor([MUTED]) - .width(14) - .height(14) - } else if (direction === 'up') { - SymbolGlyph($r('sys.symbol.chevron_up')) - .fontSize(18) - .fontColor([MUTED]) - .width(14) - .height(14) - } else { - SymbolGlyph($r('sys.symbol.chevron_down')) - .fontSize(18) - .fontColor([MUTED]) - .width(14) - .height(14) - } + ToolGlyph({ kind: `chevron_${direction}`, color: MUTED }) } @Builder @@ -483,99 +283,6 @@ export struct ToolStatusList { .padding({ left: 30, top: 2 }) } - @Builder - ToolInputEditor(tool: ConversationUiToolStatus) { - Column({ space: 6 }) { - Row() { - Text(RemoteI18n.t('chat.toolInput')) - .fontSize(11) - .fontColor(MUTED) - Blank() - Text(RemoteI18n.t('chat.reset')) - .fontSize(11) - .fontColor(MUTED) - .onClick(() => { - this.toolInputEditToolId = tool.id || ''; - this.toolInputEditText = this.defaultToolInputText(tool); - this.toolInputEditError = ''; - }) - } - .width('100%') - TextArea({ placeholder: RemoteI18n.t('chat.editJsonInput'), text: this.toolInputTextForTool(tool) }) - .height(96) - .fontSize(12) - .fontColor(INK) - .lineHeight(17) - .backgroundColor(SOFT) - .borderRadius(14) - .padding(10) - .border({ width: 1, color: this.toolInputErrorForTool(tool.id || '').length > 0 ? RED : LINE }) - .defaultFocus(false) - .enabled(true) - .onChange((value: string) => { - this.toolInputEditToolId = tool.id || ''; - this.toolInputEditText = value; - this.toolInputEditError = ''; - }) - if (this.toolInputErrorForTool(tool.id || '').length > 0) { - Text(this.toolInputErrorForTool(tool.id || '')) - .fontSize(11) - .fontColor(RED) - } - } - .width('100%') - .padding({ left: 30 }) - } - - @Builder - QuestionAnswer(tool: ConversationUiToolStatus) { - Column({ space: 8 }) { - Text(this.questionPrompt(tool)) - .fontSize(12) - .lineHeight(17) - .fontColor(INK) - .width('100%') - TextArea({ placeholder: RemoteI18n.t('chat.answerPlaceholder'), text: this.answerTextForTool(tool.id || '') }) - .height(78) - .fontSize(13) - .backgroundColor(CARD) - .borderRadius(14) - .padding(12) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .enabled(true) - .onChange((value: string) => { - this.questionAnswerToolId = tool.id || ''; - this.questionAnswerText = value; - }) - Row({ space: 8 }) { - Text(RemoteI18n.t('chat.submitAnswer')) - .fontSize(12) - .fontColor(this.canSubmitQuestion(tool.id || '') ? PRIMARY_ACTION_TEXT : MUTED) - .textAlign(TextAlign.Center) - .height(32) - .layoutWeight(1) - .backgroundColor(this.canSubmitQuestion(tool.id || '') ? ACCENT : SOFT) - .borderRadius(16) - .onClick(() => { - if (this.canSubmitQuestion(tool.id || '')) { - const answer = this.questionAnswerText.trim(); - const answers: ConversationUiQuestionAnswer = { - answer, - '0': answer - }; - this.onAnswerQuestion(tool.id || '', answers); - this.questionAnswerText = ''; - this.questionAnswerToolId = ''; - } - }) - } - .width('100%') - } - .width('100%') - .padding({ left: 30 }) - } - private displayStatus(status: string): string { const normalized = (status || '').toLowerCase(); if (normalized === 'running' || normalized === 'active') { @@ -758,18 +465,6 @@ export struct ToolStatusList { }; } - private hasCollapsibleTools(): boolean { - let runLength = 0; - return this.tools.some((tool: ConversationUiToolStatus) => { - if (this.shouldCollapseExploreTool(tool)) { - runLength += 1; - return runLength >= 2; - } - runLength = 0; - return false; - }); - } - private shouldCollapseExploreTool(tool: ConversationUiToolStatus): boolean { if (this.hasToolError(tool) || this.isPendingConfirmation(tool) || this.isQuestionTool(tool) || this.isRunningTool(tool)) { @@ -823,6 +518,29 @@ export struct ToolStatusList { return (tool.name || 'Tool').replace(/[\s-]/g, '_').toLowerCase(); } + private summaryGlyphKind(entry: ToolRenderEntry): string { + if (entry.searchCount > 0 && entry.readCount === 0) return 'search'; + if (entry.readCount > 0 && entry.searchCount === 0) return 'document'; + return 'stack'; + } + + private toolGlyphKind(tool: ConversationUiToolStatus): string { + if (this.isQuestionLikeTool(tool)) return 'question'; + if (this.isTodoTool(tool)) return 'todo'; + if (this.isTaskTool(tool)) return 'task'; + if (this.isGitTool(tool)) return 'git'; + if (this.isDeleteTool(tool)) return 'delete'; + if (this.isDiffTool(tool)) return 'diff'; + if (this.isPatchTool(tool)) return 'patch'; + if (this.isFileCreateTool(tool)) return 'create'; + if (this.isFileMutationTool(tool)) return 'mutate'; + if (this.isFileReadTool(tool)) return this.isDirectoryListTool(tool) ? 'folder' : 'document'; + if (this.isSearchTool(tool)) return 'search'; + if (this.isWebTool(tool)) return 'web'; + if (this.isCommandTool(tool)) return 'command'; + return 'tool'; + } + private isQuestionLikeTool(tool: ConversationUiToolStatus): boolean { const normalized = this.normalizedToolName(tool); return this.isQuestionTool(tool) || normalized === 'askuserquestion' || normalized === 'ask_user_question'; @@ -1280,50 +998,6 @@ export struct ToolStatusList { } } - private toolInputTextForTool(tool: ConversationUiToolStatus): string { - const toolId = tool.id || ''; - if (this.toolInputEditToolId === toolId) { - return this.toolInputEditText; - } - return this.defaultToolInputText(tool); - } - - private toolInputErrorForTool(toolId: string): string { - return this.toolInputEditToolId === toolId ? this.toolInputEditError : ''; - } - - private approveToolWithInput(tool: ConversationUiToolStatus): void { - const toolId = tool.id || ''; - if (toolId.length === 0) { - return; - } - if (!this.hasEditableToolInput(tool)) { - this.onApproveTool(toolId); - return; - } - - const rawInput = this.toolInputTextForTool(tool).trim(); - if (rawInput.length === 0) { - this.toolInputEditToolId = toolId; - this.toolInputEditError = RemoteI18n.t('chat.jsonObjectRequired'); - return; - } - - try { - const parsed = JSON.parse(rawInput) as Object; - if (parsed === null || Array.isArray(parsed)) { - this.toolInputEditToolId = toolId; - this.toolInputEditError = RemoteI18n.t('chat.jsonObjectRequired'); - return; - } - this.toolInputEditError = ''; - this.onApproveTool(toolId, parsed); - } catch (_err) { - this.toolInputEditToolId = toolId; - this.toolInputEditError = RemoteI18n.t('chat.jsonInvalid'); - } - } - private isRunningTool(tool: ConversationUiToolStatus): boolean { const status = (tool.status || '').toLowerCase(); return (status === 'running' || status === 'active') && (tool.id || '').length > 0; @@ -1387,16 +1061,6 @@ export struct ToolStatusList { return ''; } - private answerTextForTool(toolId: string): string { - return this.questionAnswerToolId === toolId ? this.questionAnswerText : ''; - } - - private canSubmitQuestion(toolId: string): boolean { - return toolId.length > 0 && - this.questionAnswerToolId === toolId && - this.questionAnswerText.trim().length > 0; - } - private toolKey(tool: ConversationUiToolStatus, index: number): string { const signature = this.toolSignature(tool); if (tool.id && tool.id.length > 0) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets new file mode 100644 index 000000000..8db93d9fa --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -0,0 +1,319 @@ +import { RemoteUiState } from '../../services/RemoteUiState'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { WideLayoutGeometry } from '../layout/WideLayoutGeometry'; +import { AppRoute, ConversationSource } from '../navigation/AppRouteContract'; +import { FilePreviewLayout, FilePreviewPlacement } from '../policy/FilePreviewPlacementPolicy'; +import { AppShellState } from '../state/AppShellState'; +import { FilePreviewState } from '../state/FilePreviewState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { RemotePageState } from '../state/RemotePageState'; +import { AppSidebar } from './AppSidebar'; +import { ConversationRouteSurface } from './ConversationRouteSurface'; +import { FilePreviewSurface } from './FilePreviewSurface'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; +import { SidebarToggleButton } from './SidebarToggleButton'; +import { FLOATING_PANEL_BG, LINE, PAGE_BG } from './Theme'; + +const WIDE_DETAIL_CONTENT_MAX_WIDTH: number = 920; + +@ComponentV2 +export struct WideConversationHost { + @Param route: AppRoute = AppRoute.ChatHome; + @Param shellState: AppShellState = new AppShellState(); + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param filePreviewState: FilePreviewState = new FilePreviewState(); + @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Param filePreviewLayout: FilePreviewLayout = new FilePreviewLayout(FilePreviewPlacement.Hidden); + @Param wideMasterPaneWidth: number = 0; + @Param wideMasterDetailGap: number = 0; + @Param wideDetailContentOffset: number = 0; + @Param wideDetailContentWidth: number = 0; + @Param wideCollapsedDetailContentOffset: number = 0; + @Param wideCollapsedDetailContentWidth: number = 0; + @Param wideMasterPaneCollapsed: boolean = false; + @Param wideMasterPaneMotionActive: boolean = false; + @Event onCollapseMasterPane: () => void = () => {}; + @Event onRestoreMasterPane: () => void = () => {}; + @Event onOpenRemoteViewSettings: () => void = () => {}; + + build() { + if (this.route === AppRoute.ChatHome || this.route === AppRoute.GeneralChat) { + this.GeneralChatContent(); + } else if (this.showsRemoteConversation() && + this.filePreviewLayout.placement === FilePreviewPlacement.WideFocusSplit) { + this.RemotePreviewFocusContent(); + } else if (this.showsRemoteConversation()) { + this.RemoteChatContent(); + } else if (this.route === AppRoute.RemoteHome) { + this.RemoteHomeContent(); + } else { + this.RemoteCreateContent(); + } + } + + @Builder + private GeneralChatContent() { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.General, false) + this.MasterDetailGap() + } + this.ConversationDetail(false) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private RemoteHomeContent() { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.Remote, false) + this.MasterDetailGap() + } + Column() { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Placeholder, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + wideMasterPaneCollapsed: this.wideMasterPaneCollapsed, + onRestoreSidebar: this.onRestoreMasterPane + }) + } + .layoutWeight(1).height('100%').backgroundColor(PAGE_BG) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private RemoteCreateContent() { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.Remote, false) + this.MasterDetailGap() + } + this.ConversationDetail(false) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private MasterPane(source: ConversationSource, showSelectedSession: boolean) { + Column() { + Column() { + AppSidebar({ + sessions: source === ConversationSource.Remote ? [] : this.generalPageState.recentSessions(), + pinnedSessionId: this.generalPageState.pinnedSessionId(), + selectedSessionId: source === ConversationSource.Remote ? '' : + this.generalPageState.conversation.activeSession.sessionId, + connectionState: this.remotePageState.connectionState, + accountUserId: this.remotePageState.accountUserId, + activeSection: source === ConversationSource.Remote ? 'remote' : 'chat', + showConversationSourceSwitcher: true, + showCollapseButton: true, + showViewSettingsButton: source === ConversationSource.Remote, + showCustomContent: source === ConversationSource.Remote, + conversationSource: source, + contentSlot: () => { + this.RemoteMasterContent(showSelectedSession) + }, + onClose: this.actions.onSidebar.close, + onNewChat: source === ConversationSource.Remote ? + this.actions.onRemoteHome.createAssistant : this.actions.onSidebar.newChat, + onEnterCode: () => this.actions.onWideConversationSource(ConversationSource.Remote), + onConversationSource: this.actions.onWideConversationSource, + onCollapse: this.onCollapseMasterPane, + onOpenViewSettings: this.onOpenRemoteViewSettings, + onSearchQueryChange: (query: string) => { + if (source === ConversationSource.Remote) this.actions.onRemoteHome.queryChanged(query); + }, + onOpenSettings: source === ConversationSource.Remote ? + this.actions.onRemoteHome.openSettings : this.actions.onSidebar.settings, + onOpenAccount: this.actions.onSidebar.openAccount, + onOpenSession: this.actions.onSidebar.openSession, + onArchiveSession: this.actions.onSidebar.archive, + onExportSession: this.actions.onSidebar.exportSession, + onDeleteSession: this.actions.onSidebar.deleteSession + }) + } + .width('100%').height('100%').backgroundColor(FLOATING_PANEL_BG) + .borderRadius(18).clip(true) + .shadow({ radius: 24, color: '#14000000', offsetX: 4, offsetY: 8 }) + } + .width(WideLayoutGeometry.masterPaneWidth(this.filePreviewLayout, this.wideMasterPaneWidth)) + .height('100%').padding({ left: 10, right: 6, top: 10, bottom: 10 }).backgroundColor(PAGE_BG) + .transition(this.wideMasterPaneMotionActive ? + TransitionEffect.translate({ x: -28, y: 0 }).combine(TransitionEffect.opacity(0)) + .animation({ duration: 220, curve: Curve.EaseInOut }) : TransitionEffect.opacity(1)) + } + + @Builder + private RemoteMasterContent(showSelectedSession: boolean) { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Master, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + showSelectedSession, + compact: false + }) + } + + @Builder + private RemoteChatContent() { + if (this.filePreviewLayout.placement === FilePreviewPlacement.WideTriplePane) { + Row() { + this.MasterPane(ConversationSource.Remote, true) + this.PaneGap(this.filePreviewLayout.masterConversationGap) + this.ConversationDetail(false, this.filePreviewLayout.conversationPaneWidth) + this.PaneGap(this.filePreviewLayout.conversationPreviewGap) + this.FilePreviewPane(this.filePreviewLayout.previewPaneWidth) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } else { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.Remote, true) + this.MasterDetailGap() + } + this.ConversationDetail(false) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + } + + @Builder + private RemotePreviewFocusContent() { + Row() { + this.ConversationDetail(false, this.filePreviewLayout.conversationPaneWidth) + this.PaneGap(this.filePreviewLayout.conversationPreviewGap) + this.FilePreviewPane(this.filePreviewLayout.previewPaneWidth) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private FilePreviewPane(paneWidth: number) { + Column() { + FilePreviewSurface({ + state: this.filePreviewState, + remoteAvailable: RemoteUiState.canUseRemote(this.remotePageState.connectionState), + downloadPath: this.remotePageState.downloadingFilePath, + downloadedPath: this.remotePageState.downloadedFilePath, + downloadStatus: this.remotePageState.fileDownloadStatus, + onClose: this.actions.onFilePreview.close, + onRefresh: this.actions.onFilePreview.refresh, + onDownload: this.actions.onFilePreview.download, + onOpenLink: this.actions.onFilePreview.openLink + }) + } + .width(paneWidth).height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private ConversationDetail(showBackButton: boolean, paneWidth: number = 0) { + if (paneWidth > 0) { + Column() { + this.RouteSurface(showBackButton) + } + .width(paneWidth).height('100%').constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) + .backgroundColor(PAGE_BG) + } else { + Stack({ alignContent: Alignment.TopStart }) { + Row() { + if (this.currentDetailOffset() > 0) Blank().width(this.currentDetailOffset()) + Row() { + Column() { this.RouteSurface(showBackButton) } + .width('100%').height('100%').constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) + .backgroundColor(PAGE_BG) + } + .width(this.currentDetailWidth() > 0 ? this.currentDetailWidth() : '100%') + .height('100%').justifyContent(FlexAlign.Center) + if (this.currentDetailOffset() > 0) Blank().layoutWeight(1) + } + .width('100%').height('100%').justifyContent(FlexAlign.Center).backgroundColor(PAGE_BG) + + if (this.wideMasterPaneCollapsed) { + SidebarToggleButton({ restore: true, controlSize: 44, onToggle: this.onRestoreMasterPane }) + .position({ x: this.currentDetailOffset() + 12, y: 12 }).zIndex(2) + .transition(TransitionEffect.scale({ x: 0.9, y: 0.9 }).combine(TransitionEffect.opacity(0)) + .animation({ duration: 180, curve: Curve.EaseOut })) + } + } + .layoutWeight(1).height('100%').backgroundColor(PAGE_BG) + } + } + + @Builder + private RouteSurface(showBackButton: boolean) { + ConversationRouteSurface({ + route: this.route, + remotePageState: this.remotePageState, + remoteCreateState: this.remoteCreateState, + generalPageState: this.generalPageState, + filePreviewState: this.filePreviewState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + showSidebarButton: false, + showBackButton, + useWidePresentation: true, + contentHorizontalOffset: this.collapsedDetailVisualBias(), + onRestoreSidebar: this.onRestoreMasterPane + }) + } + + @Builder + private MasterDetailGap() { + if (this.wideMasterDetailGap > 0) { + Row() {}.width(this.wideMasterDetailGap).height('100%').backgroundColor(LINE) + } + } + + @Builder + private PaneGap(width: number) { + if (width > 0) { + Row() {}.width(width).height('100%').backgroundColor(LINE) + } + } + + private showsRemoteConversation(): boolean { + return this.route === AppRoute.RemoteChat; + } + + private currentDetailOffset(): number { + return WideLayoutGeometry.detailOffset( + this.wideMasterPaneCollapsed, + this.wideDetailContentOffset, + this.wideCollapsedDetailContentOffset + ); + } + + private currentDetailWidth(): number { + return WideLayoutGeometry.detailWidth( + this.wideMasterPaneCollapsed, + this.wideDetailContentWidth, + this.wideCollapsedDetailContentWidth + ); + } + + private collapsedDetailVisualBias(): number { + return WideLayoutGeometry.collapsedVisualBias( + this.wideMasterPaneCollapsed, + this.wideCollapsedDetailContentOffset, + this.wideCollapsedDetailContentWidth, + WIDE_DETAIL_CONTENT_MAX_WIDTH, + 72 + ); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets new file mode 100644 index 000000000..11f1f41e0 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -0,0 +1,368 @@ +import { RemoteI18n } from '../../../i18n/RemoteI18n'; +import { RemoteSession } from '../../../model/RemoteModels'; +import { RemotePageState } from '../../state/RemotePageState'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../../actions/AppRootPresentationActions'; +import { ConversationViewSettings } from '../ConversationViewSettings'; +import { GeneralChatHeader } from '../GeneralChatHeader'; +import { RemoteSessionList } from '../RemoteSessionList'; +import { RemoteSessionLoadingView } from '../RemoteSessionLoadingView'; +import { SidebarToggleButton } from '../SidebarToggleButton'; +import { SessionActionPresentation } from '../SessionActionSurface'; +import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED } from '../Theme'; + +export enum RemoteSurfaceMode { + Master = 'master', + CompactHome = 'compact_home', + Placeholder = 'placeholder', + Settings = 'settings' +} + +/** Shared presentation state for compact and wide Remote surfaces. */ +@ObservedV2 +export class RemoteSurfaceState { + @Trace sortMode: string = 'project'; + @Trace workspaceFilter: string = ''; + @Trace agentFilter: string = ''; + @Trace statusFilter: string = ''; + @Trace showWorkspaceMetadata: boolean = false; + @Trace showUpdatedMetadata: boolean = false; + @Trace showStatusMetadata: boolean = false; + + setSortMode(value: string): void { this.sortMode = value; } + setWorkspaceFilter(value: string): void { this.workspaceFilter = value; } + setAgentFilter(value: string): void { this.agentFilter = value; } + setStatusFilter(value: string): void { this.statusFilter = value; } + setWorkspaceMetadata(value: boolean): void { this.showWorkspaceMetadata = value; } + setUpdatedMetadata(value: boolean): void { this.showUpdatedMetadata = value; } + setStatusMetadata(value: boolean): void { this.showStatusMetadata = value; } +} + +@ComponentV2 +export struct RemoteSurfaceHost { + @Param mode: RemoteSurfaceMode = RemoteSurfaceMode.Master; + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param presentationState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Param showSelectedSession: boolean = false; + @Param compact: boolean = false; + @Param wideMasterPaneCollapsed: boolean = false; + @Event onOpenSidebar: () => void = () => {}; + @Event onRestoreSidebar: () => void = () => {}; + @Event onCloseSettings: () => void = () => {}; + + build() { + if (this.mode === RemoteSurfaceMode.Master) { + this.MasterContent(); + } else if (this.mode === RemoteSurfaceMode.CompactHome) { + this.CompactHomeContent(); + } else if (this.mode === RemoteSurfaceMode.Placeholder) { + this.FlowPlaceholder(); + } else { + this.SettingsContent(); + } + } + + @Builder + private MasterContent() { + Column() { + this.StatusRow() + if (this.isInitialLoading()) { + RemoteSessionLoadingView() + } else if (this.canShowSessionList()) { + RemoteSessionList({ + sessions: this.remotePageState.visibleSessions(), + query: this.remotePageState.sessionQuery, + sortMode: this.presentationState.sortMode, + workspaceFilter: this.presentationState.workspaceFilter, + agentFilter: this.presentationState.agentFilter, + statusFilter: this.presentationState.statusFilter, + workspaceName: this.remotePageState.workspaceName, + workspacePath: this.remotePageState.workspacePath, + workspaceKind: this.remotePageState.workspaceKind, + recentWorkspaces: this.remotePageState.recentWorkspaces, + actionPresentation: SessionActionPresentation.Popover, + showWorkspaceMetadata: this.presentationState.showWorkspaceMetadata, + showUpdatedMetadata: this.presentationState.showUpdatedMetadata, + showStatusMetadata: this.presentationState.showStatusMetadata, + hasMoreSessions: this.remotePageState.hasMoreSessions, + isBusy: this.remotePageState.conversation.isBusy || this.remotePageState.isLoadingSessions, + selectedSessionId: this.remotePageState.pendingSessionId.length > 0 ? + this.remotePageState.pendingSessionId : + (this.showSelectedSession ? this.remotePageState.conversation.activeSession.sessionId : ''), + onCreate: () => this.createSession('code'), + onCreateAssistantSession: () => this.createAssistantSession(), + onCreateInWorkspace: (path: string, agentType: string) => this.createSessionInWorkspace(path, agentType), + onSelectWorkspace: (path: string) => this.actions.onRemoteHome.selectWorkspace(path), + onOpenSession: (session: RemoteSession) => this.openSession(session), + onDeleteSession: (session: RemoteSession) => this.actions.onRemoteHome.deleteSession(session), + onLoadMore: () => this.actions.onRemoteHome.loadMore() + }) + } else { + this.DisconnectedState() + } + } + .width('100%') + .height('100%') + .alignItems(HorizontalAlign.Start) + .padding({ bottom: 84 }) + } + + @Builder + private StatusRow() { + Row({ space: 6 }) { + this.StatusIndicator() + Text(this.statusText()) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .width('100%') + .margin({ top: 16, bottom: 6 }) + .alignItems(VerticalAlign.Center) + } + + @Builder + private StatusIndicator() { + if (this.isInitialLoading()) { + LoadingProgress().width(14).height(14).color(MUTED) + } else { + Stack() { + Text('') + } + .width(7) + .height(7) + .backgroundColor(this.statusColor()) + .borderRadius(4) + } + } + + @Builder + private DisconnectedState() { + Column({ space: 12 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.desktop')).fontSize(42).fontColor([INK]) + } + .width(74) + .height(74) + .backgroundColor(CARD) + .borderRadius(24) + .border({ width: 1, color: LINE }) + Text(RemoteI18n.t('remote.connectTitle')) + .fontSize(18).fontWeight(FontWeight.Bold).fontColor(INK).textAlign(TextAlign.Center) + Text(RemoteI18n.t('remote.connectText')) + .fontSize(13).lineHeight(20).fontColor(MUTED).textAlign(TextAlign.Center) + Text(RemoteI18n.t('connect.connect')) + .width(136).height(44).fontSize(15).fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION).textAlign(TextAlign.Center).borderRadius(22) + .onClick(() => this.actions.onRemoteHome.connectWorkspace()) + } + .layoutWeight(1) + .width('100%') + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .padding({ left: 20, right: 20, bottom: 48 }) + } + + @Builder + private CompactHomeContent() { + Column() { + GeneralChatHeader({ + title: RemoteI18n.t('remote.title'), + subtitle: this.compactHeaderContext(), + showSidebarButton: true, + onOpenSidebar: this.onOpenSidebar + }) + if (this.canShowSessionList()) { + this.CompactEmptyState() + } else { + this.DisconnectedState() + } + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + private CompactEmptyState() { + Column({ space: 10 }) { + if (this.isInitialLoading()) { + LoadingProgress().width(28).height(28).color(MUTED).margin({ bottom: 8 }) + } + Text(this.compactTitle()) + .fontSize(20).fontWeight(FontWeight.Bold).fontColor(INK).textAlign(TextAlign.Center) + Text(this.compactText()) + .fontSize(14).lineHeight(21).fontColor(MUTED).maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }).textAlign(TextAlign.Center) + .constraintSize({ maxWidth: 280 }) + Text(RemoteI18n.t('remote.startSession')) + .width(148).height(46).fontSize(15).fontWeight(FontWeight.Medium) + .fontColor(PRIMARY_ACTION_TEXT).backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center).borderRadius(23).margin({ top: 12 }) + .onClick(() => this.actions.onRemoteHome.createAssistant()) + } + .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center).padding({ left: 24, right: 24, bottom: 56 }) + } + + @Builder + private FlowPlaceholder() { + Column() { + Row({ space: 8 }) { + if (this.wideMasterPaneCollapsed) { + SidebarToggleButton({ restore: true, controlSize: 48, onToggle: this.onRestoreSidebar }) + } else { + Blank().width(48).height(48) + } + Column({ space: 4 }) { + Text(RemoteI18n.t('remote.chats')).fontSize(20).fontWeight(FontWeight.Bold).fontColor(INK) + Text(this.desktopName()).fontSize(13).fontColor(MUTED).maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1).alignItems(HorizontalAlign.Center) + Blank().width(48).height(48) + } + .width('100%').height(76).padding({ left: 16, right: 16, top: 14, bottom: 12 }) + .border({ width: { bottom: 1 }, color: LINE }) + + Column({ space: 8 }) { + if (this.isInitialLoading()) { + LoadingProgress().width(28).height(28).color(MUTED).margin({ bottom: 8 }) + } + Text(this.placeholderTitle()).fontSize(22).fontWeight(FontWeight.Bold).fontColor(INK) + Text(this.statusText()).fontSize(14).fontColor(MUTED).maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center).padding({ left: 24, right: 24, bottom: 48 }) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private SettingsContent() { + ConversationViewSettings({ + sessions: this.remotePageState.visibleSessions(), + workspaceName: this.remotePageState.workspaceName, + workspacePath: this.remotePageState.workspacePath, + workspaceKind: this.remotePageState.workspaceKind, + recentWorkspaces: this.remotePageState.recentWorkspaces, + sortMode: this.presentationState.sortMode, + workspaceFilter: this.presentationState.workspaceFilter, + agentFilter: this.presentationState.agentFilter, + statusFilter: this.presentationState.statusFilter, + showWorkspaceMetadata: this.presentationState.showWorkspaceMetadata, + showUpdatedMetadata: this.presentationState.showUpdatedMetadata, + showStatusMetadata: this.presentationState.showStatusMetadata, + onSortModeChange: (value: string) => this.presentationState.setSortMode(value), + onWorkspaceFilterChange: (value: string) => this.presentationState.setWorkspaceFilter(value), + onAgentFilterChange: (value: string) => this.presentationState.setAgentFilter(value), + onStatusFilterChange: (value: string) => this.presentationState.setStatusFilter(value), + onWorkspaceMetadataChange: (value: boolean) => this.presentationState.setWorkspaceMetadata(value), + onUpdatedMetadataChange: (value: boolean) => this.presentationState.setUpdatedMetadata(value), + onStatusMetadataChange: (value: boolean) => this.presentationState.setStatusMetadata(value), + onClose: this.onCloseSettings + }) + } + + private openSession(session: RemoteSession): void { + if (this.compact) { + this.actions.onSidebar.openSession(session); + } else { + this.actions.onRemoteHome.openSessionInPlace(session); + } + } + + private createSession(agentType: string): void { + if (this.compact) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.create(agentType); + } else { + this.actions.onRemoteHome.createInPlace(agentType); + } + } + + private createSessionInWorkspace(path: string, agentType: string): void { + if (this.compact) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.createInWorkspace(path, agentType); + } else { + this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType); + } + } + + private createAssistantSession(): void { + if (this.compact) { + this.actions.onSidebar.close(); + } + this.actions.onRemoteHome.createAssistant(); + } + + private canShowSessionList(): boolean { + return this.remotePageState.connectionState === 'connected' || + this.remotePageState.visibleSessions().length > 0 || + this.remotePageState.isLoadingHome || this.remotePageState.isLoadingSessions; + } + + private isInitialLoading(): boolean { + return this.remotePageState.isLoadingHome || this.isConnecting(); + } + + private isConnecting(): boolean { + return this.remotePageState.connectionState === 'parsing' || + this.remotePageState.connectionState === 'pairing' || + this.remotePageState.connectionState === 'reconnecting'; + } + + private statusText(): string { + if (this.remotePageState.conversation.statusText.length > 0) { + return this.remotePageState.conversation.statusText; + } + return this.desktopName(); + } + + private statusColor(): ResourceColor { + if (this.remotePageState.connectionState === 'connected') return GREEN; + if (this.remotePageState.connectionState === 'failed' || this.remotePageState.connectionState === 'disconnected') { + return RED; + } + return MUTED; + } + + /** + * Compact Remote Home names the bound desktop under the title, the same + * context the conversation header carries. Stays empty while disconnected so + * the connect state does not advertise a stale desktop. + */ + private compactHeaderContext(): string { + return this.canShowSessionList() ? this.remotePageState.desktopName : ''; + } + + private desktopName(): string { + return this.remotePageState.desktopName.length > 0 ? this.remotePageState.desktopName : + RemoteI18n.t('remote.settings.noDesktop'); + } + + private compactTitle(): string { + if (this.isInitialLoading()) return RemoteI18n.t('common.loading'); + return this.remotePageState.visibleSessions().length > 0 ? + RemoteI18n.t('remote.pickSession') : RemoteI18n.t('remote.emptyTitle'); + } + + private compactText(): string { + if (this.isInitialLoading()) return this.statusText(); + return this.remotePageState.visibleSessions().length > 0 ? + RemoteI18n.t('remote.pickSessionText') : RemoteI18n.t('remote.emptyText'); + } + + private placeholderTitle(): string { + if (this.isInitialLoading()) return RemoteI18n.t('common.loading'); + return this.remotePageState.visibleSessions().length > 0 ? '选择会话' : RemoteI18n.t('remote.emptyTitle'); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets new file mode 100644 index 000000000..4b3de8888 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets @@ -0,0 +1,35 @@ +import { FilePreviewLayout, FilePreviewPlacement } from '../policy/FilePreviewPlacementPolicy'; + +/** Pure geometry helpers shared by wide conversation presentation paths. */ +export class WideLayoutGeometry { + static masterPaneWidth(layout: FilePreviewLayout, fallback: number): number { + return layout.placement === FilePreviewPlacement.WideTriplePane ? layout.masterPaneWidth : fallback; + } + + static detailOffset(collapsed: boolean, expandedOffset: number, collapsedOffset: number): number { + return collapsed ? collapsedOffset : expandedOffset; + } + + static detailWidth(collapsed: boolean, expandedWidth: number, collapsedWidth: number): number { + return collapsed ? collapsedWidth : expandedWidth; + } + + static collapsedVisualBias( + collapsed: boolean, + collapsedOffset: number, + collapsedWidth: number, + maxContentWidth: number, + maximumBias: number + ): number { + if (!collapsed || collapsedOffset > 0) { + return 0; + } + const availableMargin = (collapsedWidth - maxContentWidth) / 2; + return Math.min(maximumBias, Math.max(0, availableMargin)); + } + + static areaLength(value: Object): number { + const parsed = Number.parseFloat(`${value}`); + return Number.isNaN(parsed) ? 0 : parsed; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AppRootRouteState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRootRouteState.ets similarity index 78% rename from src/apps/mobile/harmonyos/entry/src/main/ets/services/AppRootRouteState.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRootRouteState.ets index 417e97869..58ca1bf03 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AppRootRouteState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRootRouteState.ets @@ -1,8 +1,8 @@ -import { SelectedImageAttachment } from '../model/RemoteModels'; -import { AppRoute, AppRouteContract } from '../pages/navigation/AppRouteContract'; -import { GeneralChatPageState } from '../pages/state/GeneralChatPageState'; -import { RemotePageState } from '../pages/state/RemotePageState'; -import { VoiceInputRouteSnapshot } from './VoiceInputLifecycleController'; +import { SelectedImageAttachment } from '../../model/RemoteModels'; +import { VoiceInputRouteSnapshot } from '../../services/VoiceInputLifecycleController'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { AppRoute, AppRouteContract } from './AppRouteContract'; /** Keeps route-dependent composer state mapping out of the root component. */ export class AppRootRouteState { @@ -11,16 +11,19 @@ export class AppRootRouteState { } static chatInput(route: AppRoute, general: GeneralChatPageState, remote: RemotePageState): string { - return AppRootRouteState.isGeneralComposerRoute(route) ? general.chatInput : remote.chatInput; + return AppRootRouteState.isGeneralComposerRoute(route) ? + general.conversation.chatInput : remote.conversation.chatInput; } static selectedImages(route: AppRoute, general: GeneralChatPageState, remote: RemotePageState): SelectedImageAttachment[] { - return AppRootRouteState.isGeneralComposerRoute(route) ? general.selectedImages : remote.selectedImages; + return AppRootRouteState.isGeneralComposerRoute(route) ? + general.conversation.selectedImages : remote.conversation.selectedImages; } static voiceListening(route: AppRoute, general: GeneralChatPageState, remote: RemotePageState): boolean { - return AppRootRouteState.isGeneralComposerRoute(route) ? general.isVoiceListening : remote.isVoiceListening; + return AppRootRouteState.isGeneralComposerRoute(route) ? + general.conversation.isVoiceListening : remote.conversation.isVoiceListening; } static setChatInput(route: AppRoute, value: string, general: GeneralChatPageState, remote: RemotePageState): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets index 0ca41eeb8..32a712b54 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets @@ -85,6 +85,10 @@ export class AppRouteContract { return new ChatRouteParam(sessionId); } + static remoteSessionDestination(sessionId: string): AppNavigationPathSpec { + return new AppNavigationPathSpec(AppRoute.RemoteChat, sessionId); + } + static pathSpec(currentRoute: AppRoute, route: AppRoute, sessionId: string = ''): AppNavigationPathSpec | undefined { if (currentRoute === route) { return undefined; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationModelPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationModelPresentationPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationSessionFilterPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationSessionFilterPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationSessionFilterPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationSessionFilterPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewPlacementPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewPlacementPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SessionActionPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionActionPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SessionActionPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionActionPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets new file mode 100644 index 000000000..7d34cede7 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -0,0 +1,390 @@ +import { RemoteSession } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { AppRootHostPort } from '../host/AppRootHostAdapter'; +import { + AppNavigationBackAction, + AppRoute, + AppRouteContract, + ConversationSource +} from '../navigation/AppRouteContract'; +import { + AppRootRuntimeComposition, + ConnectionState +} from './AppRootRuntimeComposition'; + +export class AppRootRuntime extends AppRootRuntimeComposition { + constructor(host: AppRootHostPort) { + super(host); + } + + async aboutToAppear(): Promise { + this.syncRemotePageSummary(); + await this.generalChatBootstrapController.restore(this.host.context()); + await this.settingsController.initializeCloudAccount(this.host.context()); + await this.settingsController.refreshModelCatalog(); + await this.restoreIdentity(); + } + + onPageShow(): void { + RemoteLogger.info(`page show state=${(this.remotePageState.connectionState as ConnectionState)} route=${this.appShellViewModel.currentRoute()}`); + this.remoteActivityViewModel.resume(); + } + + onPageHide(): void { + RemoteLogger.info(`page hide state=${(this.remotePageState.connectionState as ConnectionState)} route=${this.appShellViewModel.currentRoute()}`); + this.remoteActivityViewModel.invalidate(); + this.remoteConnectionCoordinator.invalidate(); + this.remotePageState.setBusy(false); + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + } + + aboutToDisappear(): void { + this.remoteActivityViewModel.invalidate(); + this.remoteConnectionCoordinator.invalidate(); + this.remotePageState.setBusy(false); + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + this.generalChatConversationViewModel.stop(true, 'failed'); + this.generalChatDraftLifecycleController.cancel(); + this.remoteFileDownloadController.cancel(); + this.filePreviewController.close(); + this.voiceInputLifecycleController.cancel(`${this.appShellViewModel.currentRoute()}`, () => { + this.conversationController.clearAllVoiceListening(); + }); + } + + isRemoteConversationContext(sessionId: string): boolean { + if (sessionId.length === 0 || this.remotePageState.activeSession.sessionId !== sessionId) { + return false; + } + return this.appShellViewModel.isRoute(AppRoute.RemoteChat) || this.appShellViewModel.isRoute(AppRoute.RemoteHome); + } + + handleNavigationBack(route: AppRoute): boolean { + if (this.filePreviewState.visible) { + this.filePreviewController.close(); + return true; + } + const action = this.appShellViewModel.backAction(route); + if (action === AppNavigationBackAction.CloseSidebar) { + this.closeAppSidebar(); + return true; + } + if (action === AppNavigationBackAction.CloseActiveChat) { + this.exitActiveChat(); + return true; + } + if (action === AppNavigationBackAction.PopRemoteHome) { + this.appShellViewModel.popRoute(AppRoute.ChatHome); + return true; + } + return false; + } + + handleRootBack(): boolean { + if (!this.filePreviewState.visible) { + return false; + } + this.filePreviewController.close(); + return true; + } + + + async restoreIdentity(): Promise { + if (this.remotePageState.controlTargetType === 'account_device') { + return; + } + await this.remoteConnectionController.restore(this.host.context()); + } + + async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { + await this.remoteConnectionController.connect(autoReconnect, accountPassword); + await this.settingsController.persistDelegatedAccountSession(); + } + + async reconnect(): Promise { + if (this.remotePageState.controlTargetType === 'account_device') { + await this.settingsController.restoreCloudTarget( + this.remotePageState.controlTargetDeviceId, + this.remotePageState.controlTargetDeviceName + ); + return; + } + await this.remoteConnectionController.reconnect(); + } + + async disconnect(clearPairing: boolean): Promise { + this.filePreviewController.invalidate(); + await this.remoteConnectionController.disconnect(clearPairing); + } + + syncRemotePageSummary(): void { + if (this.remotePageState.statusText.length === 0) { + this.remotePageState.setStatusText(RemoteI18n.t('status.waitingConnection')); + } + if (this.remotePageState.workspaceName.length === 0) { + this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + } + } + + failRemoteConnection(err: Object): void { + this.remotePageState.setStatusText(ConnectionErrorPolicy.errorText(err)); + this.remotePageState.setConnectionState(ConnectionState.Failed); + this.remoteActivityViewModel.stopHeartbeat(); + } + + async selectWorkspace(path: string): Promise { + this.filePreviewController.close(); + await this.remoteWorkspaceViewModel.selectWorkspace(path); + } + + async selectAssistant(path: string): Promise { + this.filePreviewController.close(); + await this.remoteWorkspaceViewModel.selectAssistant(path); + } + + openAppSidebar(): void { + this.host.animate(230, () => { + this.appShellState.setSidebarVisible(true); + }); + } + + closeAppSidebar(): void { + this.host.animate(210, () => { + this.appShellState.setSidebarVisible(false); + }); + } + + enterCodeEntry(): void { + if (this.settingsController.hasCloudAccountSession() && this.remotePageState.accountUserId.trim().length > 0) { + this.appShellState.setConnectSheetVisible(true); + return; + } + if (RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState))) { + this.appShellState.setConnectSheetVisible(false); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + return; + } + this.appShellState.setConnectSheetVisible(true); + } + + async switchWideConversationSource(source: ConversationSource): Promise { + if (AppRouteContract.conversationSource(this.appShellViewModel.currentRoute()) === source) { + return; + } + if (this.conversationController.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + if (source === ConversationSource.General) { + this.remoteChatPollingLifecycleController.stop(); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); + return; + } + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + const activeRemoteSessionId = this.remotePageState.isConversationDismissed ? '' : + (this.remotePageState.activeSession.sessionId || ''); + const target = AppRouteContract.routeForConversationSource( + source, + RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)), + activeRemoteSessionId + ); + this.appShellViewModel.replaceRouteWithoutAnimation( + target.name, + target.hasSessionParam() ? target.routeParam().sessionId : '' + ); + if (target.name === AppRoute.RemoteChat) { + this.conversationController.startRemotePolling(); + await this.conversationController.loadRemoteMessages(); + } + } + + /** + * Compact counterpart of switchWideConversationSource. Switching source is a + * change of context, not a command to start something: it resumes the session + * the user was last in, and otherwise rests on the Remote landing surface + * rather than opening the create composer for them. + */ + async switchCompactConversationSource(source: ConversationSource): Promise { + this.closeAppSidebar(); + if (AppRouteContract.conversationSource(this.appShellViewModel.currentRoute()) === source) { + return; + } + if (this.conversationController.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + if (source === ConversationSource.General) { + this.remoteChatPollingLifecycleController.stop(); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); + return; + } + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + const activeRemoteSessionId = RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)) && + !this.remotePageState.isConversationDismissed ? + (this.remotePageState.activeSession.sessionId || '') : ''; + if (activeRemoteSessionId.length === 0) { + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + return; + } + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteChat, activeRemoteSessionId); + this.conversationController.startRemotePolling(); + await this.conversationController.loadRemoteMessages(); + } + + enterCompactLayout(): void { + const sessionId = this.remotePageState.activeSession.sessionId || ''; + if (this.appShellViewModel.isRoute(AppRoute.RemoteHome) && + !this.remotePageState.isConversationDismissed && sessionId.length > 0) { + this.appShellViewModel.pushRoute(AppRoute.RemoteChat, sessionId, false); + } + } + + /** + * Exit control for an open conversation. Leaving a remote conversation on a + * compact layout lands on Remote Home with no visible session list, so reveal + * the drawer that owns navigation there. Compact chats have no back button of + * their own, so this runs for the system back gesture. + */ + exitActiveChat(): void { + const revealSidebar = !this.appShellState.wideLayout && + this.appShellViewModel.isRoute(AppRoute.RemoteChat); + this.conversationController.closeActiveChat(); + if (revealSidebar) { + this.openAppSidebar(); + } + } + + openRemoteControlSettings(): void { + setTimeout(() => { + this.appShellState.openSettings('remote'); + }, 180); + } + + openAddConnectionFromSettings(): void { + this.appShellState.setSettingsVisible(false); + setTimeout(() => { + this.appShellState.setConnectSheetVisible(true); + }, 220); + } + + applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void { + this.remoteWorkspaceSessions = all; + const current = this.remotePageState.sessions; + const extras = all.filter((item: RemoteSession) => item.workspacePath !== this.remotePageState.workspacePath); + this.remotePageState.setSessions(this.mergeSessions(current, extras), this.remotePageState.hasMoreSessions); + } + + mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[] { + const merged = primary.slice(); + extras.forEach((item: RemoteSession) => { + if (!merged.some((existing: RemoteSession) => existing.id === item.id)) { + merged.push(item); + } + }); + return merged; + } + + async toggleVoiceInput(): Promise { + const route = this.appShellViewModel.currentRoute(); + await this.voiceInputLifecycleController.toggle( + this.host.context(), this.conversationController.voiceInputSnapshot(route) + ); + } + + async stopVoiceInput(showStatus: boolean): Promise { + const route = this.appShellViewModel.currentRoute(); + await this.voiceInputLifecycleController.stop( + this.conversationController.voiceInputSnapshot(route), showStatus + ); + } + + showVoiceInputError(message: string): void { + const text = message.length > 0 ? message : RemoteI18n.t('errors.voiceInputUnavailable'); + this.conversationController.setVisibleStatusText(text); + this.host.showToast(text, 2600); + } + + async pickImages(): Promise { + if (this.conversationController.visibleBusy()) { + return; + } + const route = this.appShellViewModel.currentRoute(); + if (this.conversationController.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + try { + this.conversationController.setVisibleStatusText(RemoteI18n.t('status.pickImage')); + const picked = await this.imagePickerService.pickImages( + 3, + this.conversationController.visibleSelectedImages().length + ); + if (picked.length === 0) { + this.conversationController.setVisibleStatusText(RemoteI18n.t('status.noImageSelected')); + return; + } + this.conversationController.addSelectedImages(route, picked); + this.conversationController.setVisibleStatusText(RemoteI18n.f( + 'status.imagesSelected', + `${this.conversationController.visibleSelectedImages().length}` + )); + } catch (err) { + this.conversationController.setVisibleStatusText(ConnectionErrorPolicy.errorText(err)); + } + } + + currentActiveTurnId(): string { + if (!this.appShellViewModel.isGeneralChatVisible()) { + return this.conversationController.remoteActiveTurnId(); + } + const activeTurnMessage = this.generalChatPageState.activeTurnMessage; + if (activeTurnMessage.turnId && activeTurnMessage.turnId.length > 0) { + return activeTurnMessage.turnId; + } + const activePrefix = 'active-'; + if (activeTurnMessage.id.indexOf(activePrefix) === 0) { + return activeTurnMessage.id.slice(activePrefix.length); + } + return ''; + } + + hasRemoteBindingForResume(): boolean { + if (this.remotePageState.controlTargetType === 'account_device') { + return this.remotePageState.accountUserId.trim().length > 0 && + this.remotePageState.controlTargetDeviceId.trim().length > 0 && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Idle && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Disconnected; + } + return this.remotePageState.remoteUrl.trim().length > 0 && + this.remotePageState.userId.trim().length > 0 && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Idle && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Parsing && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Pairing && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Disconnected; + } + + async reconnectActiveRemote(): Promise { + if (this.remotePageState.controlTargetType !== 'account_device') { + await this.connect(true); + return; + } + const targetId = this.remotePageState.controlTargetDeviceId; + const device = (await this.settingsController.listCloudAccountDevices()) + .find((item: CloudAccountDevice): boolean => item.deviceId === targetId); + if (!device) { + throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); + } + await this.settingsController.selectCloudAccountDevice(device); + } + + hasRemoteBindingForCodeHome(): boolean { + return this.remotePageState.remoteUrl.trim().length > 0 && + this.remotePageState.userId.trim().length > 0 && + ((this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected || + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Reconnecting || + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Pairing || + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Parsing); + } + +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets new file mode 100644 index 000000000..c2d87b233 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -0,0 +1,803 @@ +import { + ChatMessage, + RemoteModelCatalog, + RemotePermissionMode, + RemoteQuestionAnswerPayload, + RemoteSession, + SelectedImageAttachment, + SessionSummary, + WorkspaceInfo +} from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ClipboardService } from '../../services/ClipboardService'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { ImagePickerService } from '../../services/ImagePickerService'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore +} from '../../services/general-chat/GeneralChatConfigStore'; +import { GeneralChatBootstrapController } from '../../services/general-chat/GeneralChatBootstrapController'; +import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; +import { GeneralChatController } from '../../services/general-chat/GeneralChatController'; +import { GeneralChatDraftController } from '../../services/general-chat/GeneralChatDraftController'; +import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; +import { + GeneralChatSendResult, + GeneralChatStreamCallbacks +} from '../../services/general-chat/GeneralChatPort'; +import { MobileIdentityStore } from '../../services/MobileIdentityStore'; +import { CloudAccountClient, CloudAccountDevice } from '../../services/CloudAccountClient'; +import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; +import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; +import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; +import { RemoteChatPollingLifecycleController, RemoteChatPollingSnapshot } from '../../services/RemoteChatPollingLifecycleController'; +import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; +import { FilePreviewController } from '../viewmodel/FilePreviewController'; +import { SettingsController } from '../viewmodel/SettingsController'; +import { ConversationController } from '../viewmodel/ConversationController'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteModelController } from '../../services/RemoteModelController'; +import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; +import { RemoteSessionController } from '../../services/RemoteSessionController'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemoteWorkspaceRepository } from '../../services/RemoteWorkspaceRepository'; +import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; +import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; +import { RemoteToolActionController } from '../../services/RemoteToolActionController'; +import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; +import { QrScanService } from '../../services/QrScanService'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { VoiceInputLifecycleController } from '../../services/VoiceInputLifecycleController'; +import { VoiceInputService } from '../../services/VoiceInputService'; +import { ConversationIntent } from '../actions/ConversationIntent'; +import { AppRootHostPort } from '../host/AppRootHostAdapter'; +import { AppRootPresentationActions } from '../actions/AppRootPresentationActions'; +import { + AppNavigationBackAction, + AppRoute, + AppRouteContract, + ConversationSource +} from '../navigation/AppRouteContract'; +import { AppShellState } from '../state/AppShellState'; +import { AppShellViewModel } from '../viewmodel/AppShellViewModel'; +import { RemoteActivityViewModel } from '../viewmodel/RemoteActivityViewModel'; +import { + RemoteConnectionController +} from '../viewmodel/RemoteConnectionController'; +import { ConversationIntentDispatcher } from '../actions/ConversationIntentDispatcher'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { ConversationViewModel } from '../viewmodel/ConversationViewModel'; +import { FilePreviewState } from '../state/FilePreviewState'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; +import { RemoteWorkspaceViewModel } from '../viewmodel/RemoteWorkspaceViewModel'; +import { RemoteSessionViewModel } from '../viewmodel/RemoteSessionViewModel'; +import { GeneralChatConversationViewModel } from '../viewmodel/GeneralChatConversationViewModel'; +import { ModelProviderGeneralChatAdapter } from '../../services/general-chat/ModelProviderGeneralChatAdapter'; + +export enum ConnectionState { + Idle = 'idle', + Parsing = 'parsing', + Pairing = 'pairing', + Connected = 'connected', + Reconnecting = 'reconnecting', + Failed = 'failed', + Disconnected = 'disconnected' +} + +const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; +const GENERAL_CHAT_DRAFT_SAVE_DELAY_MS: number = 250; + +export abstract class AppRootRuntimeComposition { + readonly host: AppRootHostPort; + + constructor(host: AppRootHostPort) { + this.host = host; + } + + abstract applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void; + abstract closeAppSidebar(): void; + abstract connect(autoReconnect?: boolean, accountPassword?: string): Promise; + abstract currentActiveTurnId(): string; + abstract disconnect(clearPairing: boolean): Promise; + abstract enterCodeEntry(): void; + abstract enterCompactLayout(): void; + abstract exitActiveChat(): void; + abstract failRemoteConnection(err: Object): void; + abstract handleNavigationBack(route: AppRoute): boolean; + abstract hasRemoteBindingForResume(): boolean; + abstract isRemoteConversationContext(sessionId: string): boolean; + abstract mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[]; + abstract openAddConnectionFromSettings(): void; + abstract openAppSidebar(): void; + abstract openRemoteControlSettings(): void; + abstract pickImages(): Promise; + abstract reconnect(): Promise; + abstract reconnectActiveRemote(): Promise; + abstract selectAssistant(path: string): Promise; + abstract selectWorkspace(path: string): Promise; + abstract showVoiceInputError(message: string): void; + abstract stopVoiceInput(showStatus: boolean): Promise; + abstract switchCompactConversationSource(source: ConversationSource): Promise; + abstract switchWideConversationSource(source: ConversationSource): Promise; + abstract toggleVoiceInput(): Promise; + + readonly sessionManager: RemoteSessionManager = new RemoteSessionManager(); + readonly workspaceRepository: RemoteWorkspaceRepository = + new RemoteWorkspaceRepository(this.sessionManager); + readonly workspaceCoordinator: RemoteWorkspaceCoordinator = + new RemoteWorkspaceCoordinator(this.workspaceRepository); + readonly remoteResumeGate: AsyncLifecycleGate = new AsyncLifecycleGate(); + readonly remoteConnectionGate: AsyncLifecycleGate = new AsyncLifecycleGate(); + readonly filePreviewState: FilePreviewState = new FilePreviewState(); + readonly identityStore: MobileIdentityStore = new MobileIdentityStore(); + readonly clipboardService: ClipboardService = new ClipboardService(); + readonly qrScanService: QrScanService = new QrScanService(); + readonly imagePickerService: ImagePickerService = new ImagePickerService(); + readonly remotePairingPolicy: RemotePairingPolicy = new RemotePairingPolicy(); + readonly remoteConnectionCoordinator: RemoteConnectionCoordinator = + new RemoteConnectionCoordinator( + this.sessionManager, + this.identityStore, + this.remotePairingPolicy, + this.remoteConnectionGate + ); + readonly generalChatPageState: GeneralChatPageState = new GeneralChatPageState(); + readonly remotePageState: RemotePageState = new RemotePageState(); + readonly remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore(); + readonly generalChatController: GeneralChatController = + GeneralChatController.createDefault(this.generalChatConfigStore); + readonly generalChatDraftController: GeneralChatDraftController = + new GeneralChatDraftController( + this.generalChatController, + GENERAL_CHAT_DRAFT_SAVE_DELAY_MS, + (err: Error) => { + RemoteLogger.warn(`general chat draft operation failed: ${ConnectionErrorPolicy.errorText(err)}`); + } + ); + readonly generalChatDraftLifecycleController: GeneralChatDraftLifecycleController = + new GeneralChatDraftLifecycleController( + this.generalChatDraftController, + GENERAL_CHAT_HOME_DRAFT_ID, + (): string => this.conversationController.visibleGeneralChatDraftId() + ); + readonly chatTimelineStore: ConversationViewModel = new ConversationViewModel(); + readonly generalChatCommandController: GeneralChatCommandController = + new GeneralChatCommandController( + this.generalChatController, + { + onSessions: (sessions: RemoteSession[]) => { + this.generalChatPageState.setSessions(sessions); + }, + onSessionPrepared: (sessionId: string) => { + this.conversationController.resetGeneralTimeline(sessionId); + this.remoteModelController.clearCatalog(); + }, + onActiveSession: (session: SessionSummary) => { + this.generalChatPageState.setActiveSession(session); + }, + onMessagesLoaded: (messages: ChatMessage[]) => { + this.chatTimelineStore.setPersistedMessages(messages); + this.conversationController.syncGeneralTimeline(); + }, + onClearComposer: () => { + this.generalChatPageState.clearComposer(); + }, + onChatInput: (text: string) => { + this.generalChatPageState.setChatInput(text); + }, + onStatusText: (statusText: string) => { + this.generalChatPageState.setStatus(statusText); + }, + onBusy: (isBusy: boolean) => { + this.generalChatPageState.setBusy(isBusy); + }, + onToast: (statusText: string) => { + this.conversationController.showHomeToast(statusText); + } + } + ); + readonly generalChatBootstrapController: GeneralChatBootstrapController = + new GeneralChatBootstrapController( + this.generalChatConfigStore, + this.generalChatCommandController, + this.generalChatDraftLifecycleController, + { + onConfigRestored: (snapshot: GeneralChatConfigSnapshot) => { + this.settingsController.apply(snapshot); + }, + onHomeDraftRestored: (text: string) => { + this.generalChatPageState.setChatInput(text); + }, + onStatusText: (statusText: string) => { + this.generalChatPageState.setStatus(statusText); + } + } + ); + readonly voiceInputService: VoiceInputService = new VoiceInputService(); + readonly remoteActivityLifecycleController: RemoteActivityLifecycleController = + new RemoteActivityLifecycleController(() => { + this.remoteActivityViewModel.checkConnectionHealth(); + }); + readonly remoteActivityViewModel: RemoteActivityViewModel = + new RemoteActivityViewModel( + this.remoteActivityLifecycleController, + this.remoteConnectionCoordinator, + this.remoteResumeGate, + { + isConnected: (): boolean => (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected, + isBusy: (): boolean => this.remotePageState.isBusy, + hasRemoteBinding: (): boolean => this.hasRemoteBindingForResume(), + isRemoteChat: (): boolean => this.appShellViewModel.isRoute(AppRoute.RemoteChat), + activeSession: (): SessionSummary => this.remotePageState.activeSession, + onConnectionState: (state: string): void => this.remotePageState.setConnectionState(state as ConnectionState), + onStatus: (status: string): void => this.remotePageState.setStatusText(status), + onConnectionError: async (err: Object): Promise => this.settingsController.handleRemoteConnectionError(err), + onStopHeartbeat: (): void => this.remoteActivityViewModel.stopHeartbeat(), + onStartPolling: (): void => this.conversationController.startRemotePolling(), + onStopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), + onPoll: async (): Promise => { + await this.remoteChatPollingLifecycleController.pollNow(); + }, + onReconnect: async (): Promise => { + await this.reconnectActiveRemote(); + }, + onRestoreSession: async (session: SessionSummary): Promise => { + this.conversationController.applyRemoteActiveSession(session); + await this.conversationController.loadRemoteMessages(); + } + } + ); + readonly generalChatStreamLifecycleController: GeneralChatStreamLifecycleController = + new GeneralChatStreamLifecycleController(); + readonly remoteWorkspaceViewModel: RemoteWorkspaceViewModel = + new RemoteWorkspaceViewModel( + this.remotePageState, + this.workspaceCoordinator, + { + isRemoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), + isBusy: (): boolean => this.remotePageState.isBusy, + onBusy: (isBusy: boolean): void => { + this.remotePageState.setBusy(isBusy); + }, + onStatus: (statusText: string): void => { + this.remotePageState.setStatusText(statusText); + }, + onWorkspaceSelected: (workspace: WorkspaceInfo): void => { + this.remoteConnectionController.applyWorkspace(workspace); + this.remoteSessionController.clearSessions(); + }, + onSessionsDiscovered: (sessions: RemoteSession[]): void => { + this.applyDiscoveredWorkspaceSessions(sessions); + }, + onRefreshSessions: async (): Promise => { + await this.remoteSessionViewModel.refreshSessions(); + }, + onConnectionFailure: (error: Object): void => { + this.failRemoteConnection(error); + } + } + ); + remoteWorkspaceSessions: RemoteSession[] = []; + readonly appShellViewModel: AppShellViewModel = new AppShellViewModel(); + readonly appShellState: AppShellState = this.appShellViewModel.state; + readonly voiceInputLifecycleController: VoiceInputLifecycleController = + new VoiceInputLifecycleController( + this.voiceInputService, + { + currentInputText: (): string => this.conversationController.visibleChatInput(), + currentStatusText: (): string => this.conversationController.visibleStatusText(), + onInputText: (routeId: string, text: string) => { + this.conversationController.setChatInput(routeId as AppRoute, text); + }, + onListening: (routeId: string, isListening: boolean) => { + this.conversationController.setVoiceListening(routeId as AppRoute, isListening); + }, + onStatusText: (statusText: string) => { + this.conversationController.setVisibleStatusText(statusText); + }, + onError: (message: string) => { + this.showVoiceInputError(message); + } + } + ); + readonly remoteSessionController: RemoteSessionController = + new RemoteSessionController( + this.sessionManager, + 8, + { + onSessions: (sessions: RemoteSession[], hasMore: boolean) => { + const extras = this.remoteWorkspaceSessions.filter((item: RemoteSession) => { + return item.workspacePath !== this.remotePageState.workspacePath; + }); + this.remotePageState.setSessions(this.mergeSessions(sessions, extras), hasMore); + }, + onActiveSession: (session: SessionSummary) => { + this.conversationController.applyRemoteActiveSession(session); + }, + onStatusText: (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + onBusy: (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + }, + onLoading: (isLoading: boolean) => { + this.remotePageState.setLoading(isLoading); + }, + onSessionError: (errorText: string) => { + this.remotePageState.setError(errorText); + }, + onReconnecting: () => { + this.remotePageState.setConnectionState(ConnectionState.Reconnecting); + }, + onConnected: () => { + this.remotePageState.setConnectionState(ConnectionState.Connected); + }, + onConnectionFailed: (err: Object) => { + this.failRemoteConnection(err); + }, + onStartHeartbeat: () => { + this.remoteActivityViewModel.startHeartbeat(); + } + } + ); + readonly remoteChatCommandController: RemoteChatCommandController = + new RemoteChatCommandController( + this.sessionManager, + { + onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { + this.chatTimelineStore.setPersistedMessages(messages); + this.remotePageState.setHasMoreMessages(hasMoreMessages); + this.conversationController.syncRemoteTimeline(); + }, + onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { + this.conversationController.updateKnownMessageCount(pollVersion, knownMessageCount); + }, + onSendSucceeded: (turnId: string, pendingActiveId: string) => { + if (turnId.length > 0) { + this.chatTimelineStore.setLocalActiveTurn(turnId); + this.conversationController.syncRemoteTimeline(); + } else if (pendingActiveId.length > 0) { + this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); + this.conversationController.syncRemoteTimeline(); + } + this.remoteChatPollingLifecycleController.nudge(); + }, + onSendFailed: ( + rawText: string, + images: SelectedImageAttachment[], + localMessageId: string, + pendingActiveId: string + ) => { + this.remotePageState.setChatInput(rawText); + this.remotePageState.setSelectedImages(images); + this.chatTimelineStore.markOptimisticMessageFailed(localMessageId); + if (pendingActiveId.length > 0) { + this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); + } + this.conversationController.syncRemoteTimeline(); + }, + onActiveSession: (session: SessionSummary) => { + this.conversationController.applyRemoteActiveSession(session); + }, + onSessionTitleChanged: (sessionId: string, title: string) => { + this.remoteSessionController.updateSessionTitle(sessionId, title); + }, + onStatusText: (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + onBusy: (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + }, + onPollRequested: () => { + this.remoteChatPollingLifecycleController.pollNow(); + } + } + ); + readonly remoteFileDownloadController: RemoteFileDownloadController = + new RemoteFileDownloadController( + this.sessionManager, + (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { + this.remotePageState.setDownloadStatus(downloadingFilePath, downloadedFilePath, fileDownloadStatus); + }, + () => { + this.remotePageState.clearDownloadingFilePath(); + }, + (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + } + ); + readonly filePreviewController: FilePreviewController = + new FilePreviewController( + this.sessionManager, + this.filePreviewState, + { + remoteAvailable: (): boolean => RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)), + activeSession: (): SessionSummary => this.remotePageState.conversation.activeSession, + workspacePath: (): string => this.remotePageState.workspacePath, + openExternalLink: async (reference: string): Promise => + this.host.openExternalLink ? await this.host.openExternalLink(reference) : false, + onGeneralStatus: (statusText: string): void => this.generalChatPageState.setStatus(statusText), + onRemoteStatus: (statusText: string): void => this.remotePageState.setStatusText(statusText) + } + ); + readonly remoteToolActionController: RemoteToolActionController = + new RemoteToolActionController( + this.sessionManager, + (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + }, + () => { + this.remoteChatPollingLifecycleController.pollNow(); + } + ); + readonly remoteChatPollingLifecycleController: RemoteChatPollingLifecycleController = + new RemoteChatPollingLifecycleController( + this.sessionManager, + { + canPoll: (sessionId: string) => { + return this.remotePageState.activeSession.sessionId === sessionId && + this.isRemoteConversationContext(sessionId) && + this.remoteConnectionController.ensureAvailable(); + }, + onSnapshot: (snapshot: RemoteChatPollingSnapshot) => { + this.conversationController.applyRemoteSnapshot(snapshot); + }, + onError: (error: Object) => { + this.remotePageState.setStatusText(ConnectionErrorPolicy.errorText(error)); + } + } + ); + readonly remoteModelController: RemoteModelController = + new RemoteModelController( + this.sessionManager, + this.identityStore, + (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { + this.conversationController.updateKnownModelCatalogVersion(knownModelCatalogVersion); + this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); + }, + (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { + this.conversationController.updateKnownModelCatalogVersion(knownModelCatalogVersion); + this.chatTimelineStore.setModelCatalog(modelCatalog, selectedModelId); + this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); + }, + (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + } + ); + readonly remoteSessionViewModel: RemoteSessionViewModel = + new RemoteSessionViewModel( + this.remotePageState, + this.remoteSessionController, + this.remoteChatCommandController, + this.remoteModelController, + this.remoteFileDownloadController, + { + remoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), + isConnected: (): boolean => (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected, + isBusy: (): boolean => this.remotePageState.isBusy, + onBusy: (busy: boolean): void => this.remotePageState.setBusy(busy), + onRouteChat: (sessionId: string): void => this.conversationController.routeCreatedRemoteSession(sessionId), + onRouteHome: (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), + onStopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), + onStartPolling: (): void => this.conversationController.startRemotePolling(), + onResetTimeline: (sessionId: string): void => this.conversationController.resetRemoteTimeline(sessionId), + onClearRemoteFiles: (): void => this.remoteFileDownloadController.clear(), + onKnownStateReset: (): void => this.conversationController.resetKnownRemoteState(), + onLoadModelCatalog: async (sessionId: string): Promise => { + await this.conversationController.loadRemoteModelCatalog(sessionId); + }, + onLoadActiveMessages: async (): Promise => { + await this.conversationController.loadRemoteMessages(); + }, + onRefreshSessions: async (): Promise => { + await this.remoteSessionController.refresh( + this.remotePageState.sessionQuery, + this.remotePageState.sessionFilter, + this.remoteConnectionController.ensureAvailable(), + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected + ); + }, + onSelectWorkspace: async (path: string): Promise => { + await this.selectWorkspace(path); + } + } + ); + readonly generalChatConversationViewModel: GeneralChatConversationViewModel = + new GeneralChatConversationViewModel( + this.generalChatPageState, + this.generalChatCommandController, + this.generalChatDraftLifecycleController, + this.generalChatStreamLifecycleController, + this.chatTimelineStore, + { + isVisible: (sessionId: string): boolean => this.generalChatPageState.activeSession.sessionId === sessionId && + this.appShellViewModel.isGeneralChatVisible(), + currentActiveTurnId: (): string => this.currentActiveTurnId(), + latestUserMessageText: (): string => this.conversationController.latestUserMessageText(), + syncTimeline: (): void => this.conversationController.syncGeneralTimeline(), + refreshSessions: (): void => this.generalChatCommandController.refreshSessions() + } + ); + readonly remoteConnectionController: RemoteConnectionController = + new RemoteConnectionController( + this.remotePageState, + this.identityStore, + this.remotePairingPolicy, + this.remoteConnectionCoordinator, + this.remoteSessionController, + this.remoteModelController, + this.remoteFileDownloadController, + this.clipboardService, + this.qrScanService, + (sessionId: string): void => this.conversationController.resetRemoteTimeline(sessionId), + (): void => this.conversationController.resetKnownRemoteState(), + (): void => this.remoteActivityViewModel.startHeartbeat(), + (): void => this.remoteActivityViewModel.stopHeartbeat(), + (): void => this.remoteChatPollingLifecycleController.stop(), + async (): Promise => { + await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); + }, + (route: AppRoute): void => this.appShellViewModel.replaceRouteWithoutAnimation(route), + (): void => this.appShellState.setConnectSheetVisible(false), + (): void => this.appShellState.setConnectSheetVisible(true) + ); + readonly settingsController: SettingsController = + new SettingsController( + this.generalChatConfigStore, + this.generalChatPageState, + { + probeConfiguration: async (apiUrl: string, apiKey: string, modelName: string): Promise => { + await ModelProviderGeneralChatAdapter.probeConfiguration(apiUrl, apiKey, modelName); + } + }, + { + client: new CloudAccountClient(), + sessionStore: new CloudAccountSessionStore(), + sessionManager: this.sessionManager, + remoteState: this.remotePageState, + hooks: { + deviceId: (): string => this.remoteConnectionController.getDeviceId(), + remoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), + invalidatePreview: (): void => this.filePreviewController.invalidate(), + invalidateRemoteActivity: (): void => this.remoteActivityViewModel.invalidate(), + invalidateRemoteConnection: (): void => this.remoteConnectionCoordinator.invalidate(), + stopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), + stopHeartbeat: (): void => this.remoteActivityViewModel.stopHeartbeat(), + startHeartbeat: (): void => this.remoteActivityViewModel.startHeartbeat(), + resetTimeline: (): void => this.conversationController.resetRemoteTimeline(''), + resetKnownRemoteState: (): void => this.conversationController.resetKnownRemoteState(), + closeSettings: (): void => this.appShellState.setSettingsVisible(false), + closeConnectSheet: (): void => this.appShellState.setConnectSheetVisible(false), + navigateRemoteHome: (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), + loadRecentWorkspaces: async (): Promise => { + await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); + } + } + } + ); + readonly conversationController: ConversationController = + new ConversationController( + this.generalChatPageState, + this.remotePageState, + this.remoteCreateState, + { currentRoute: (): AppRoute => this.appShellViewModel.currentRoute() }, + { + timeline: this.chatTimelineStore, + chat: this.remoteChatCommandController, + polling: this.remoteChatPollingLifecycleController, + models: this.remoteModelController, + files: this.remoteFileDownloadController, + tools: this.remoteToolActionController, + connection: this.remoteConnectionController, + imagePicker: this.imagePickerService, + clipboard: this.clipboardService, + sessions: this.remoteSessionViewModel, + sessionManager: this.sessionManager, + workspace: this.workspaceCoordinator, + settings: this.settingsController, + appShell: this.appShellViewModel, + filePreview: this.filePreviewController, + generalCommands: this.generalChatCommandController, + generalConversation: this.generalChatConversationViewModel, + generalDrafts: this.generalChatDraftLifecycleController, + hooks: { + isConversationContext: (sessionId: string): boolean => this.isRemoteConversationContext(sessionId), + isFilePreviewVisible: (): boolean => this.filePreviewState.visible, + stopVoiceInput: async (): Promise => this.stopVoiceInput(false), + showToast: (message: string): boolean => this.host.showToast(message, 2600), + selectAssistantWorkspace: async (path: string): Promise => { + await this.selectAssistant(path); + } + } + } + ); + readonly conversationIntentDispatcher: ConversationIntentDispatcher = + new ConversationIntentDispatcher({ + openSidebar: (): void => this.openAppSidebar(), + back: (): void => this.exitActiveChat(), + newRemoteSession: (): void => { this.conversationController.createRemoteSession('code'); }, + newGeneralSession: (): void => this.conversationController.prepareNewGeneralChat(), + activeGeneralSession: (): RemoteSession => this.conversationController.activeGeneralChatAsRemoteSession(), + activeGeneralSessionId: (): string => this.generalChatPageState.activeSession.sessionId, + isGeneralBusy: (): boolean => this.generalChatPageState.isBusy, + isPinned: (sessionId: string): boolean => this.generalChatPageState.pinnedSessionId() === sessionId, + pin: async (session: RemoteSession, pinned: boolean, busy: boolean): Promise => { + await this.generalChatCommandController.pinSession(session, pinned, busy); + }, + archive: async (session: RemoteSession): Promise => { + await this.conversationController.archiveHomeSession(session, true); + }, + delete: async (session: RemoteSession): Promise => { + await this.conversationController.deleteHomeSession(session); + this.conversationController.prepareNewGeneralChat(); + }, + showToast: (text: string): void => this.conversationController.showHomeToast(text), + uploadedFileCount: (): number => this.conversationController.activeGeneralUploadedFileCount(), + stop: async (): Promise => { await this.conversationController.stopVisibleTask(); }, + loadOlder: async (): Promise => { await this.conversationController.loadOlderRemoteMessages(); }, + approve: async (id: string, input?: Object): Promise => { + await this.conversationController.approveRemoteTool(id, input); + }, + reject: async (id: string): Promise => { await this.conversationController.rejectRemoteTool(id); }, + cancel: async (id: string): Promise => { await this.conversationController.cancelRemoteTool(id); }, + answer: async (id: string, answers: RemoteQuestionAnswerPayload): Promise => { + await this.conversationController.answerRemoteQuestion(id, answers); + }, + rename: async (title: string): Promise => { + await this.conversationController.renameVisibleSession(title); + }, + copy: async (text: string): Promise => { await this.conversationController.copyRemoteMessage(text); }, + retry: async (text: string): Promise => { await this.conversationController.retryVisibleMessage(text); }, + selectModel: async (id: string): Promise => { await this.conversationController.selectVisibleModel(id); }, + pickImages: async (): Promise => { await this.pickImages(); }, + removeImage: (id: string): void => this.conversationController.removeSelectedImage(this.appShellViewModel.currentRoute(), id), + openFilePreview: (route: AppRoute, request: FilePreviewRequest): void => + this.filePreviewController.open(route, request), + downloadFile: (path: string): void => this.conversationController.downloadVisibleFile(path), + send: async (): Promise => { await this.conversationController.sendVisibleMessage(); }, + voiceInput: async (): Promise => { await this.toggleVoiceInput(); }, + inputChanged: (route: AppRoute, value: string): void => + this.conversationController.onVisibleChatInputChange(route, value) + }); + readonly presentationActions: AppRootPresentationActions = { + onNavigationBack: (route: AppRoute): boolean => this.handleNavigationBack(route), + onConversationIntent: (route: AppRoute, intent: ConversationIntent): void => + this.conversationIntentDispatcher.dispatch(route, intent), + onCloseSidebar: (): void => this.closeAppSidebar(), + onWideConversationSource: (source: ConversationSource): void => { + this.switchWideConversationSource(source); + }, + onCompactConversationSource: (source: ConversationSource): void => { + this.switchCompactConversationSource(source); + }, + onCompactLayoutEntered: (): void => this.enterCompactLayout(), + onLayoutModeChanged: (wideLayout: boolean): void => this.appShellState.setWideLayout(wideLayout), + onRemoteHome: { + openSidebar: (): void => this.openAppSidebar(), + connectWorkspace: (): void => this.enterCodeEntry(), + addConnection: (): void => this.appShellState.setConnectSheetVisible(true), + openSettings: (): void => this.openRemoteControlSettings(), + refresh: (): void => { this.remoteSessionViewModel.refreshSessions(); }, + showWorkspaces: (): void => { this.remoteWorkspaceViewModel.toggleRecentWorkspaces(); }, + showAssistants: (): void => { this.remoteWorkspaceViewModel.toggleAssistants(); }, + selectWorkspace: (path: string): void => { this.selectWorkspace(path); }, + selectAssistant: (path: string): void => { this.selectAssistant(path); }, + cancelWorkspace: (): void => this.remotePageState.setWorkspacePickerVisible(false), + cancelAssistant: (): void => this.remotePageState.setAssistantPickerVisible(false), + queryChanged: (query: string): void => this.remotePageState.setQuery(query), + search: (): void => { this.remoteSessionViewModel.refreshSessions(); }, + loadMore: (): void => { this.remoteSessionViewModel.loadMoreSessions(); }, + reconnect: (): void => { this.reconnect(); }, + disconnect: (): void => { this.disconnect(false); }, + clearPairing: (): void => { this.disconnect(true); }, + create: (agentType: string): void => { this.conversationController.createRemoteSession(agentType); }, + createInPlace: (agentType: string): void => { + this.conversationController.createRemoteSession(agentType, true); + }, + createAssistant: (): void => { this.conversationController.openRemoteCreateSession(); }, + createInWorkspace: (path: string, agentType: string): void => { + this.conversationController.createRemoteSessionInWorkspace(path, agentType); + }, + createInWorkspaceInPlace: (path: string, agentType: string): void => { + this.conversationController.createRemoteSessionInWorkspace(path, agentType, true); + }, + openSession: (session: RemoteSession): void => this.conversationController.openHomeSession(session), + openSessionInPlace: (session: RemoteSession): void => this.conversationController.openHomeSession(session, true), + deleteSession: (session: RemoteSession): void => { this.conversationController.deleteHomeSession(session); } + }, + onRemoteCreate: { + back: (): void => this.conversationController.closeRemoteCreateSession(), + toggleDevices: (): void => { this.conversationController.toggleRemoteCreateDevices(); }, + toggleWorkspaces: (): void => { this.conversationController.toggleRemoteCreateWorkspaces(); }, + selectDevice: (device: CloudAccountDevice): void => { + this.conversationController.selectRemoteCreateDevice(device); + }, + selectWorkspace: (path: string): void => this.conversationController.selectRemoteCreateWorkspace(path), + draftChanged: (value: string): void => this.remoteCreateState.setDraft(value), + voiceInput: async (): Promise => { await this.toggleVoiceInput(); }, + selectModel: (modelId: string): void => this.remoteCreateState.setSelectedModelId(modelId), + send: (): void => { this.conversationController.submitRemoteCreateSession(); } + }, + onSidebar: { + close: (): void => this.closeAppSidebar(), + newChat: (): void => { this.closeAppSidebar(); this.conversationController.prepareNewGeneralChat(); }, + enterCode: (): void => { this.closeAppSidebar(); this.enterCodeEntry(); }, + settings: (): void => { this.closeAppSidebar(); this.appShellState.openSettings('general'); }, + openAccount: (): void => { + this.closeAppSidebar(); + setTimeout(() => this.appShellState.openSettings('account'), 180); + }, + openSession: (session: RemoteSession): void => { + this.closeAppSidebar(); + this.conversationController.openHomeSession(session); + }, + archive: (session: RemoteSession, archived: boolean): void => { + this.conversationController.archiveHomeSession(session, archived); + }, + exportSession: (session: RemoteSession): void => { this.conversationController.exportHomeSession(session); }, + deleteSession: (session: RemoteSession): void => { this.conversationController.deleteHomeSession(session); } + }, + onSettings: { + close: (): void => this.appShellState.leaveSettings(), + addConnection: (): void => this.openAddConnectionFromSettings(), + disconnect: (): void => { this.disconnect(false); }, + reconnect: (): void => { this.reconnect(); }, + openAccount: (): void => { this.appShellState.openSettings('account'); }, + cloudLogin: (relayUrl: string, username: string, password: string): Promise => + this.settingsController.loginCloudAccount(relayUrl, username, password), + cloudSync: (): Promise => this.settingsController.syncCloudAccount(), + cloudLogout: (): Promise => this.settingsController.logoutCloudAccount(), + cloudListDevices: (): Promise => this.settingsController.listCloudAccountDevices(), + getPermissionMode: (): Promise => this.settingsController.getRemotePermissionMode(), + setPermissionMode: (mode: RemotePermissionMode): Promise => + this.settingsController.setRemotePermissionMode(mode), + testGeneral: async (url: string, key: string, model: string, clear: boolean): Promise => + this.settingsController.test(url, key, model, clear), + saveGeneral: async (url: string, key: string, model: string, clear: boolean): Promise => + this.settingsController.save(url, key, model, clear) + }, + onConnect: { + back: (): void => this.appShellState.setConnectSheetVisible(false), + connect: (password?: string): void => { + // Keep connection progress on the same RemoteHome surface as the connected state. + this.appShellState.setConnectSheetVisible(false); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + this.connect(false, password || ''); + }, + clearPairing: (): void => { this.appShellState.setConnectSheetVisible(false); this.disconnect(true); }, + urlChanged: (url: string): void => { this.remotePageState.setRemoteUrl(url); this.remoteConnectionController.projectRemoteUrl(url); }, + userChanged: (user: string): void => this.remotePageState.setUserId(user), + detected: (url: string): boolean => this.remoteConnectionController.handleDetectedUrl(url), + inputVisible: (visible: boolean): void => this.remotePageState.setRemoteUrlInputVisible(visible), + paste: (): void => { this.remoteConnectionController.paste(); }, + scan: (): void => { this.remoteConnectionController.scan(this.host.context()); }, + cloudListDevices: (): Promise => this.settingsController.listCloudAccountDevices(), + cloudSelectDevice: (device: CloudAccountDevice): Promise => + this.settingsController.selectCloudAccountDevice(device) + }, + onFilePreview: { + close: (): void => this.filePreviewController.close(), + refresh: (): void => this.filePreviewController.refresh(), + download: (path: string): void => this.conversationController.downloadVisibleFile(path), + openLink: (reference: string, label: string): void => this.filePreviewController.openLink(reference, label) + }, + generalStatus: (): string => this.conversationController.generalChatHomeStatusText() + }; + readonly navigationStack: NavPathStack = this.appShellViewModel.navigationStack; + + +} + diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets deleted file mode 100644 index b4b50b623..000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets +++ /dev/null @@ -1,2608 +0,0 @@ -import { - ChatMessage, - RecentWorkspaceEntry, - RemoteModelCatalog, - RemotePermissionMode, - RemoteImageContext, - RemoteQuestionAnswerPayload, - RemoteSession, - SelectedImageAttachment, - SessionSummary, - WorkspaceInfo -} from '../../model/RemoteModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ClipboardService } from '../../services/ClipboardService'; -import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; -import { ChatTimelineState } from '../../services/ChatTimelineStore'; -import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; -import { ImagePickerService } from '../../services/ImagePickerService'; -import { - GeneralChatConfigSnapshot, - GeneralChatConfigStore, - GeneralChatConfigUpdate, - GeneralChatConfigValidator, - GeneralChatModelSelectionPolicy -} from '../../services/general-chat/GeneralChatConfigStore'; -import { GeneralChatBootstrapController } from '../../services/general-chat/GeneralChatBootstrapController'; -import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; -import { GeneralChatController } from '../../services/general-chat/GeneralChatController'; -import { GeneralChatDraftController } from '../../services/general-chat/GeneralChatDraftController'; -import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; -import { GeneralChatCloudConfigPolicy } from '../../services/general-chat/GeneralChatCloudConfigPolicy'; -import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; -import { - GeneralChatServiceState, - GeneralChatServiceStatus -} from '../../services/general-chat/GeneralChatServiceState'; -import { - GeneralChatSendResult, - GeneralChatStreamCallbacks -} from '../../services/general-chat/GeneralChatPort'; -import { MobileIdentityStore } from '../../services/MobileIdentityStore'; -import { CloudAccountClient, CloudAccountDevice, CloudAccountRequestError, CloudAccountSession } from '../../services/CloudAccountClient'; -import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; -import { Encoding } from '../../services/Encoding'; -import { AppRootRouteState } from '../../services/AppRootRouteState'; -import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; -import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; -import { - RemoteChatPollingCursor, - RemoteChatPollingLifecycleController, - RemoteChatPollingSnapshot -} from '../../services/RemoteChatPollingLifecycleController'; -import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; -import { FileReferenceKind, FileTargetResolver } from '../../services/FileTargetResolver'; -import { RemoteFilePreviewController } from '../../services/RemoteFilePreviewController'; -import { RemoteLogger } from '../../services/RemoteLogger'; -import { RemoteModelController } from '../../services/RemoteModelController'; -import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; -import { RemoteSessionController } from '../../services/RemoteSessionController'; -import { RemoteSessionManager } from '../../services/RemoteSessionManager'; -import { RemoteWorkspaceRepository } from '../../services/RemoteWorkspaceRepository'; -import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; -import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; -import { RemoteToolActionController } from '../../services/RemoteToolActionController'; -import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; -import { QrScanService } from '../../services/QrScanService'; -import { RemoteUiState } from '../../services/RemoteUiState'; -import { - VoiceInputLifecycleController, - VoiceInputRouteSnapshot -} from '../../services/VoiceInputLifecycleController'; -import { VoiceInputService } from '../../services/VoiceInputService'; -import { ConversationIntent } from '../components/ConversationIntent'; -import { AppRootHostPort } from '../host/AppRootHostAdapter'; -import { - AppRootPresentation, - AppRootPresentationActions, - ConnectPresentationActions, - FilePreviewPresentationActions, - RemoteCreatePresentationActions, - RemoteHomePresentationActions, - SettingsPresentationActions, - SidebarPresentationActions -} from '../components/AppRootPresentation'; -import { - AppNavigationBackAction, - AppRoute, - AppRouteContract, - ConversationSource -} from '../navigation/AppRouteContract'; -import { AppShellState } from './AppShellState'; -import { AppShellViewModel } from './AppShellViewModel'; -import { - RemoteActivityViewModel, - RemoteActivityViewModelHooks -} from './RemoteActivityViewModel'; -import { - RemoteConnectionViewModel -} from './RemoteConnectionViewModel'; -import { - ConversationIntentDispatcher, - ConversationIntentDispatcherHooks -} from './ConversationIntentDispatcher'; -import { GeneralChatPageState } from './GeneralChatPageState'; -import { RemotePageState } from './RemotePageState'; -import { RemoteCreateSessionState } from './RemoteCreateSessionState'; -import { ConversationViewModel } from './ConversationViewModel'; -import { FilePreviewState } from './FilePreviewState'; -import { FilePreviewRequest, FilePreviewTargetContext } from './FilePreviewTarget'; -import { - RemoteWorkspaceViewModel, - RemoteWorkspaceViewModelHooks -} from './RemoteWorkspaceViewModel'; -import { - RemoteSessionViewModel, - RemoteSessionViewModelHooks -} from './RemoteSessionViewModel'; -import { - GeneralChatConversationViewModel, - GeneralChatConversationViewModelHooks -} from './GeneralChatConversationViewModel'; -import { ModelProviderGeneralChatAdapter } from '../../services/general-chat/ModelProviderGeneralChatAdapter'; - -enum ConnectionState { - Idle = 'idle', - Parsing = 'parsing', - Pairing = 'pairing', - Connected = 'connected', - Reconnecting = 'reconnecting', - Failed = 'failed', - Disconnected = 'disconnected' -} - -const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; -const GENERAL_CHAT_DRAFT_SAVE_DELAY_MS: number = 250; - -export class AppRootRuntime { - readonly host: AppRootHostPort; - - constructor(host: AppRootHostPort) { - this.host = host; - } - - readonly sessionManager: RemoteSessionManager = new RemoteSessionManager(); - readonly workspaceRepository: RemoteWorkspaceRepository = - new RemoteWorkspaceRepository(this.sessionManager); - readonly workspaceCoordinator: RemoteWorkspaceCoordinator = - new RemoteWorkspaceCoordinator(this.workspaceRepository); - readonly remoteResumeGate: AsyncLifecycleGate = new AsyncLifecycleGate(); - readonly remoteConnectionGate: AsyncLifecycleGate = new AsyncLifecycleGate(); - readonly filePreviewState: FilePreviewState = new FilePreviewState(); - private controlTargetEpoch: number = 1; - private remoteCreateWorkspaceLoadVersion: number = 0; - readonly identityStore: MobileIdentityStore = new MobileIdentityStore(); - readonly cloudAccountClient: CloudAccountClient = new CloudAccountClient(); - readonly cloudAccountSessionStore: CloudAccountSessionStore = new CloudAccountSessionStore(); - private cloudAccountSession?: CloudAccountSession; - private cloudAccountRelayUrl: string = ''; - readonly clipboardService: ClipboardService = new ClipboardService(); - readonly qrScanService: QrScanService = new QrScanService(); - readonly imagePickerService: ImagePickerService = new ImagePickerService(); - readonly remotePairingPolicy: RemotePairingPolicy = new RemotePairingPolicy(); - readonly remoteConnectionCoordinator: RemoteConnectionCoordinator = - new RemoteConnectionCoordinator( - this.sessionManager, - this.identityStore, - this.remotePairingPolicy, - this.remoteConnectionGate - ); - readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore(); - readonly generalChatController: GeneralChatController = - GeneralChatController.createDefault(this.generalChatConfigStore); - readonly generalChatDraftController: GeneralChatDraftController = - new GeneralChatDraftController( - this.generalChatController, - GENERAL_CHAT_DRAFT_SAVE_DELAY_MS, - (err: Error) => { - RemoteLogger.warn(`general chat draft operation failed: ${ConnectionErrorPolicy.errorText(err)}`); - } - ); - readonly generalChatDraftLifecycleController: GeneralChatDraftLifecycleController = - new GeneralChatDraftLifecycleController( - this.generalChatDraftController, - GENERAL_CHAT_HOME_DRAFT_ID, - (): string => this.visibleGeneralChatDraftId() - ); - readonly chatTimelineStore: ConversationViewModel = new ConversationViewModel(); - readonly generalChatCommandController: GeneralChatCommandController = - new GeneralChatCommandController( - this.generalChatController, - { - onSessions: (sessions: RemoteSession[]) => { - this.generalChatPageState.setSessions(sessions); - }, - onSessionPrepared: (sessionId: string) => { - this.resetGeneralChatTimeline(sessionId); - this.remoteModelController.clearCatalog(); - }, - onActiveSession: (session: SessionSummary) => { - this.generalChatPageState.setActiveSession(session); - }, - onMessagesLoaded: (messages: ChatMessage[]) => { - this.chatTimelineStore.setPersistedMessages(messages); - this.syncGeneralChatTimelineFromStore(); - }, - onClearComposer: () => { - this.generalChatPageState.clearComposer(); - }, - onChatInput: (text: string) => { - this.generalChatPageState.setChatInput(text); - }, - onStatusText: (statusText: string) => { - this.generalChatPageState.setStatus(statusText); - }, - onBusy: (isBusy: boolean) => { - this.generalChatPageState.setBusy(isBusy); - }, - onToast: (statusText: string) => { - this.showHomeToast(statusText); - } - } - ); - readonly generalChatBootstrapController: GeneralChatBootstrapController = - new GeneralChatBootstrapController( - this.generalChatConfigStore, - this.generalChatCommandController, - this.generalChatDraftLifecycleController, - { - onConfigRestored: (snapshot: GeneralChatConfigSnapshot) => { - this.applyGeneralChatConfig(snapshot); - }, - onHomeDraftRestored: (text: string) => { - this.generalChatPageState.setChatInput(text); - }, - onStatusText: (statusText: string) => { - this.generalChatPageState.setStatus(statusText); - } - } - ); - readonly voiceInputService: VoiceInputService = new VoiceInputService(); - readonly remoteActivityLifecycleController: RemoteActivityLifecycleController = - new RemoteActivityLifecycleController(() => { - this.checkConnectionHealth(); - }); - readonly remoteActivityViewModel: RemoteActivityViewModel = - new RemoteActivityViewModel( - this.remoteActivityLifecycleController, - this.remoteConnectionCoordinator, - this.remoteResumeGate, - new RemoteActivityViewModelHooks( - (): boolean => this.connectionState === ConnectionState.Connected, - (): boolean => this.isBusy, - (): boolean => this.hasRemoteBindingForResume(), - (): boolean => this.isRoute(AppRoute.RemoteChat), - (): SessionSummary => this.activeSession, - (state: string): void => this.setRemoteConnectionState(state as ConnectionState), - (status: string): void => this.setRemoteStatusText(status), - async (err: Object): Promise => this.handleRemoteConnectionError(err), - (): void => this.stopHeartbeat(), - (): void => this.startPolling(), - (): void => this.stopPolling(), - async (): Promise => { - await this.pollActiveSession(); - }, - async (): Promise => { - await this.reconnectActiveRemote(); - }, - async (session: SessionSummary): Promise => { - this.applyRemoteActiveSession(session); - await this.loadActiveMessages(); - } - ) - ); - isSyncingAfterTurn: boolean = false; - knownPollVersion: number = 0; - knownModelCatalogVersion: number = 0; - knownRemoteMessageCount: number = 0; - readonly generalChatStreamLifecycleController: GeneralChatStreamLifecycleController = - new GeneralChatStreamLifecycleController(); - readonly generalChatPageState: GeneralChatPageState = new GeneralChatPageState(); - readonly remotePageState: RemotePageState = new RemotePageState(); - readonly remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); - readonly remoteWorkspaceViewModel: RemoteWorkspaceViewModel = - new RemoteWorkspaceViewModel( - this.remotePageState, - this.workspaceCoordinator, - new RemoteWorkspaceViewModelHooks( - (): boolean => this.ensureRemoteAvailable(), - (): boolean => this.isBusy, - (isBusy: boolean): void => { - this.setRemoteBusy(isBusy); - }, - (statusText: string): void => { - this.setRemoteStatusText(statusText); - }, - (workspace: WorkspaceInfo): void => { - this.applyWorkspace(workspace); - this.remoteSessionController.clearSessions(); - }, - (sessions: RemoteSession[]): void => { - this.applyDiscoveredWorkspaceSessions(sessions); - }, - async (): Promise => { - await this.refreshSessions(); - }, - (error: Object): void => { - this.failRemoteConnection(error); - } - ) - ); - remoteWorkspaceSessions: RemoteSession[] = []; - readonly appShellViewModel: AppShellViewModel = new AppShellViewModel(); - readonly appShellState: AppShellState = this.appShellViewModel.state; - readonly voiceInputLifecycleController: VoiceInputLifecycleController = - new VoiceInputLifecycleController( - this.voiceInputService, - { - currentInputText: (): string => this.visibleChatInput(), - currentStatusText: (): string => this.visibleStatusText(), - onInputText: (routeId: string, text: string) => { - this.setChatInputForRoute(routeId as AppRoute, text); - }, - onListening: (routeId: string, isListening: boolean) => { - this.setVoiceListeningForRoute(routeId as AppRoute, isListening); - }, - onStatusText: (statusText: string) => { - this.setVisibleStatusText(statusText); - }, - onError: (message: string) => { - this.showVoiceInputError(message); - } - } - ); - readonly remoteSessionController: RemoteSessionController = - new RemoteSessionController( - this.sessionManager, - 8, - { - onSessions: (sessions: RemoteSession[], hasMore: boolean) => { - const extras = this.remoteWorkspaceSessions.filter((item: RemoteSession) => { - return item.workspacePath !== this.workspacePath; - }); - this.remotePageState.setSessions(this.mergeSessions(sessions, extras), hasMore); - }, - onActiveSession: (session: SessionSummary) => { - this.applyRemoteActiveSession(session); - }, - onStatusText: (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - onBusy: (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - onLoading: (isLoading: boolean) => { - this.remotePageState.setLoading(isLoading); - }, - onSessionError: (errorText: string) => { - this.remotePageState.setError(errorText); - }, - onReconnecting: () => { - this.setRemoteConnectionState(ConnectionState.Reconnecting); - }, - onConnected: () => { - this.setRemoteConnectionState(ConnectionState.Connected); - }, - onConnectionFailed: (err: Object) => { - this.failRemoteConnection(err); - }, - onStartHeartbeat: () => { - this.startHeartbeat(); - } - } - ); - readonly remoteChatCommandController: RemoteChatCommandController = - new RemoteChatCommandController( - this.sessionManager, - { - onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { - this.chatTimelineStore.setPersistedMessages(messages); - this.remotePageState.setHasMoreMessages(hasMoreMessages); - this.syncChatTimelineFromStore(); - }, - onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { - this.knownRemoteMessageCount = knownMessageCount; - this.updateChatPollingCursor(pollVersion, knownMessageCount); - }, - onSendSucceeded: (turnId: string, pendingActiveId: string) => { - if (turnId.length > 0) { - this.chatTimelineStore.setLocalActiveTurn(turnId); - this.syncChatTimelineFromStore(); - } else if (pendingActiveId.length > 0) { - this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); - this.syncChatTimelineFromStore(); - } - this.nudgeChatPolling(); - }, - onSendFailed: ( - rawText: string, - images: SelectedImageAttachment[], - localMessageId: string, - pendingActiveId: string - ) => { - this.remotePageState.setChatInput(rawText); - this.remotePageState.setSelectedImages(images); - this.chatTimelineStore.markOptimisticMessageFailed(localMessageId); - if (pendingActiveId.length > 0) { - this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); - } - this.syncChatTimelineFromStore(); - }, - onActiveSession: (session: SessionSummary) => { - this.applyRemoteActiveSession(session); - }, - onSessionTitleChanged: (sessionId: string, title: string) => { - this.remoteSessionController.updateSessionTitle(sessionId, title); - }, - onStatusText: (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - onBusy: (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - onPollRequested: () => { - this.pollActiveSession(); - } - } - ); - readonly remoteFileDownloadController: RemoteFileDownloadController = - new RemoteFileDownloadController( - this.sessionManager, - (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { - this.remotePageState.setDownloadStatus(downloadingFilePath, downloadedFilePath, fileDownloadStatus); - }, - () => { - this.remotePageState.clearDownloadingFilePath(); - }, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - } - ); - readonly remoteFilePreviewController: RemoteFilePreviewController = - new RemoteFilePreviewController( - this.sessionManager, - this.filePreviewState, - (): boolean => RemoteUiState.canUseRemote(this.connectionState), - (): number => this.controlTargetEpoch - ); - readonly remoteToolActionController: RemoteToolActionController = - new RemoteToolActionController( - this.sessionManager, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - () => { - this.pollActiveSession(); - } - ); - readonly remoteChatPollingLifecycleController: RemoteChatPollingLifecycleController = - new RemoteChatPollingLifecycleController( - this.sessionManager, - { - canPoll: (sessionId: string) => { - return this.activeSession.sessionId === sessionId && - this.isRemoteConversationContext(sessionId) && - this.ensureRemoteAvailable(); - }, - onSnapshot: (snapshot: RemoteChatPollingSnapshot) => { - this.applyChatSessionSnapshot(snapshot); - }, - onError: (error: Object) => { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(error)); - } - } - ); - readonly remoteModelController: RemoteModelController = - new RemoteModelController( - this.sessionManager, - this.identityStore, - (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { - this.knownModelCatalogVersion = knownModelCatalogVersion; - this.remoteChatPollingLifecycleController.updateKnownModelCatalogVersion(knownModelCatalogVersion); - this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); - }, - (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { - this.knownModelCatalogVersion = knownModelCatalogVersion; - this.remoteChatPollingLifecycleController.updateKnownModelCatalogVersion(knownModelCatalogVersion); - this.chatTimelineStore.setModelCatalog(modelCatalog, selectedModelId); - this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); - }, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - } - ); - readonly remoteSessionViewModel: RemoteSessionViewModel = - new RemoteSessionViewModel( - this.remotePageState, - this.remoteSessionController, - this.remoteChatCommandController, - this.remoteModelController, - this.remoteFileDownloadController, - new RemoteSessionViewModelHooks( - (): boolean => this.ensureRemoteAvailable(), - (): boolean => this.connectionState === ConnectionState.Connected, - (): boolean => this.isBusy, - (busy: boolean): void => this.setRemoteBusy(busy), - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId), - (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), - (): void => this.stopPolling(), - (): void => this.startPolling(), - (sessionId: string): void => this.resetChatTimeline(sessionId), - (): void => this.remoteFileDownloadController.clear(), - (): void => { - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - }, - async (sessionId: string): Promise => { - await this.remoteModelController.loadCatalog( - sessionId, - this.ensureRemoteAvailable(), - (activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - } - ); - }, - async (): Promise => { - const sessionId = this.activeSession.sessionId || ''; - await this.remoteChatCommandController.loadMessages( - sessionId, - (activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - } - ); - }, - async (): Promise => { - await this.remoteSessionController.refresh( - this.remotePageState.sessionQuery, - this.remotePageState.sessionFilter, - this.ensureRemoteAvailable(), - this.connectionState === ConnectionState.Connected - ); - }, - async (path: string): Promise => { - await this.selectWorkspace(path); - } - ) - ); - readonly generalChatConversationViewModel: GeneralChatConversationViewModel = - new GeneralChatConversationViewModel( - this.generalChatPageState, - this.generalChatCommandController, - this.generalChatDraftLifecycleController, - this.generalChatStreamLifecycleController, - this.chatTimelineStore, - new GeneralChatConversationViewModelHooks( - (sessionId: string): boolean => this.generalChatPageState.activeSession.sessionId === sessionId && - this.isGeneralChatVisible(), - (): string => this.currentActiveTurnId(), - (): string => this.latestUserMessageText(), - (): void => this.syncGeneralChatTimelineFromStore(), - (): void => this.generalChatCommandController.refreshSessions() - ) - ); - readonly remoteConnectionViewModel: RemoteConnectionViewModel = - new RemoteConnectionViewModel( - this.remotePageState, - this.identityStore, - this.remotePairingPolicy, - this.remoteConnectionCoordinator, - this.remoteSessionController, - this.remoteModelController, - this.remoteFileDownloadController, - this.clipboardService, - this.qrScanService, - (sessionId: string): void => this.resetChatTimeline(sessionId), - (): void => { - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - }, - (): void => this.startHeartbeat(), - (): void => this.stopHeartbeat(), - (): void => this.stopPolling(), - async (): Promise => { - await this.loadRecentWorkspacesInBackground(); - }, - (route: AppRoute): void => this.appShellViewModel.replaceRouteWithoutAnimation(route), - (): void => this.appShellState.setConnectSheetVisible(false), - (): void => this.appShellState.setConnectSheetVisible(true) - ); - readonly conversationIntentDispatcher: ConversationIntentDispatcher = - new ConversationIntentDispatcher(new ConversationIntentDispatcherHooks( - (): void => this.openAppSidebar(), - (): void => this.closeActiveChat(), - (): void => { this.createSession('code'); }, - (): void => this.prepareNewGeneralChat(), - (): RemoteSession => this.activeGeneralChatAsRemoteSession(), - (): string => this.generalChatPageState.activeSession.sessionId, - (): boolean => this.generalChatPageState.isBusy, - (sessionId: string): boolean => this.generalChatPageState.pinnedSessionId() === sessionId, - async (session: RemoteSession, pinned: boolean, busy: boolean): Promise => { - await this.generalChatCommandController.pinSession(session, pinned, busy); - }, - async (session: RemoteSession): Promise => { await this.archiveHomeSession(session, true); }, - async (session: RemoteSession): Promise => { - await this.deleteHomeSession(session); - this.prepareNewGeneralChat(); - }, - (text: string): void => this.showHomeToast(text), - (): number => this.activeGeneralUploadedFileCount(), - async (): Promise => { await this.stopActiveChatTask(); }, - async (): Promise => { await this.loadOlderMessages(); }, - async (id: string, input?: Object): Promise => { await this.approveTool(id, input); }, - async (id: string): Promise => { await this.rejectTool(id); }, - async (id: string): Promise => { await this.cancelTool(id); }, - async (id: string, answers: RemoteQuestionAnswerPayload): Promise => { - await this.answerQuestion(id, answers); - }, - async (title: string): Promise => { await this.renameVisibleSession(title); }, - async (text: string): Promise => { await this.copyMessage(text); }, - async (text: string): Promise => { await this.retryVisibleMessage(text); }, - async (id: string): Promise => { await this.selectModel(id); }, - async (): Promise => { await this.pickImages(); }, - (id: string): void => this.removeSelectedImage(id), - (route: AppRoute, request: FilePreviewRequest): void => this.openFilePreview(route, request), - (path: string): void => this.downloadVisibleFile(path), - async (): Promise => { await this.sendVisibleChatMessage(); }, - async (): Promise => { await this.toggleVoiceInput(); }, - (route: AppRoute, value: string): void => this.onVisibleChatInputChange(route, value) - )); - readonly presentationActions: AppRootPresentationActions = new AppRootPresentationActions( - (route: AppRoute): boolean => this.handleNavigationBack(route), - (route: AppRoute, intent: ConversationIntent): void => this.handleConversationIntent(route, intent), - (): void => this.closeAppSidebar(), - (source: ConversationSource): void => { this.switchWideConversationSource(source); }, - (source: ConversationSource): void => { this.switchCompactConversationSource(source); }, - (): void => this.enterCompactLayout(), - new RemoteHomePresentationActions( - (): void => this.openAppSidebar(), (): void => this.enterCodeEntry(), (): void => this.openAddConnection(), - (): void => this.openRemoteControlSettings(), (): void => { this.refreshSessions(); }, - (): void => { this.showRecentWorkspaces(); }, (): void => { this.showAssistants(); }, - (path: string): void => { this.selectWorkspace(path); }, (path: string): void => { this.selectAssistant(path); }, - (): void => this.remotePageState.setWorkspacePickerVisible(false), - (): void => this.remotePageState.setAssistantPickerVisible(false), - (query: string): void => this.remotePageState.setQuery(query), (): void => { this.refreshSessions(); }, - (): void => { this.loadMoreSessions(); }, (): void => { this.reconnect(); }, - (): void => { this.disconnect(false); }, (): void => { this.disconnect(true); }, - (agentType: string): void => { this.createSession(agentType); }, - (agentType: string): void => { this.createSession(agentType, true); }, - (): void => { this.openRemoteCreateSession(); }, - (path: string, agentType: string): void => { this.createSessionInWorkspace(path, agentType); }, - (path: string, agentType: string): void => { this.createSessionInWorkspace(path, agentType, true); }, - (session: RemoteSession): void => this.openHomeSession(session), - (session: RemoteSession): void => this.openHomeSessionInPlace(session), - (session: RemoteSession): void => { this.deleteHomeSession(session); } - ), - new RemoteCreatePresentationActions( - (): void => this.closeRemoteCreateSession(), - (): void => { this.toggleRemoteCreateDevices(); }, - (): void => { this.toggleRemoteCreateWorkspaces(); }, - (device: CloudAccountDevice): void => { this.selectRemoteCreateDevice(device); }, - (path: string): void => this.selectRemoteCreateWorkspace(path), - (value: string): void => this.remoteCreateState.setDraft(value), - async (): Promise => { await this.toggleVoiceInput(); }, - (modelId: string): void => this.selectRemoteCreateModel(modelId), - (): void => { this.submitRemoteCreateSession(); } - ), - new SidebarPresentationActions( - (): void => this.closeAppSidebar(), - (): void => { this.closeAppSidebar(); this.prepareNewGeneralChat(); }, - (): void => { this.closeAppSidebar(); this.enterCodeEntry(); }, - (): void => { this.closeAppSidebar(); this.appShellState.openSettings('general'); }, - (): void => { - this.closeAppSidebar(); - setTimeout(() => this.appShellState.openSettings('account'), 180); - }, - (session: RemoteSession): void => { this.closeAppSidebar(); this.openHomeSession(session); }, - (session: RemoteSession, archived: boolean): void => { this.archiveHomeSession(session, archived); }, - (session: RemoteSession): void => { this.exportHomeSession(session); }, - (session: RemoteSession): void => { this.deleteHomeSession(session); } - ), - new SettingsPresentationActions( - (): void => this.appShellState.leaveSettings(), - (): void => this.openAddConnectionFromSettings(), (): void => { this.disconnect(false); }, - (): void => { this.reconnect(); }, - (): void => { - this.appShellState.openSettings('account'); - }, - (relayUrl: string, username: string, password: string): Promise => - this.loginCloudAccount(relayUrl, username, password), - (): Promise => this.syncCloudAccount(), - (): Promise => this.logoutCloudAccount(), - (): Promise => this.listCloudAccountDevices(), - (): Promise => this.getRemotePermissionMode(), - (mode: RemotePermissionMode): Promise => this.setRemotePermissionMode(mode), - async (url: string, key: string, model: string, clear: boolean): Promise => - this.testGeneralChatConfig(url, key, model, clear), - async (url: string, key: string, model: string, clear: boolean): Promise => - this.saveGeneralChatConfig(url, key, model, clear) - ), - new ConnectPresentationActions( - (): void => this.appShellState.setConnectSheetVisible(false), - (password?: string): void => { - // Keep connection progress on the same RemoteHome surface as the - // connected state instead of showing a separate loading sheet. - this.appShellState.setConnectSheetVisible(false); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - this.connect(false, password || ''); - }, - (): void => { this.appShellState.setConnectSheetVisible(false); this.disconnect(true); }, - (url: string): void => { this.setRemoteUrl(url); this.applyRemotePairingProjection(url); }, - (user: string): void => this.setRemoteUserId(user), - (url: string): boolean => this.handleDetectedRemoteUrl(url), - (visible: boolean): void => this.setRemoteUrlInputVisible(visible), - (): void => { this.pasteRemoteUrl(); }, (): void => { this.scanRemoteUrl(); }, - (): Promise => this.listCloudAccountDevices(), - (device: CloudAccountDevice): Promise => this.selectCloudAccountDevice(device) - ), - new FilePreviewPresentationActions( - (): void => this.closeFilePreview(), - (): void => this.refreshFilePreview(), - (path: string): void => this.downloadVisibleFile(path), - (reference: string, label: string): void => this.openFilePreviewLink(reference, label) - ), - (): string => this.generalChatHomeStatusText() - ); - readonly navigationStack: NavPathStack = this.appShellViewModel.navigationStack; - - - get remoteUrl(): string { return this.remotePageState.remoteUrl; } - get userId(): string { return this.remotePageState.userId; } - get authenticatedUserId(): string { return this.remotePageState.authenticatedUserId; } - get statusText(): string { return this.remotePageState.statusText; } - get connectionState(): ConnectionState { return this.remotePageState.connectionState as ConnectionState; } - get connectionFailureKind(): string { return this.remotePageState.connectionFailureKind; } - get isBusy(): boolean { return this.remotePageState.isBusy; } - get showRemoteUrlInput(): boolean { return this.remotePageState.showRemoteUrlInput; } - get workspaceName(): string { return this.remotePageState.workspaceName; } - get workspacePath(): string { return this.remotePageState.workspacePath; } - get workspaceBranch(): string { return this.remotePageState.workspaceBranch; } - get workspaceKind(): string { return this.remotePageState.workspaceKind; } - get assistantId(): string { return this.remotePageState.assistantId; } - get desktopName(): string { return this.remotePageState.desktopName; } - get desktopId(): string { return this.remotePageState.desktopId; } - get activeSession(): SessionSummary { return this.remotePageState.activeSession; } - get messages(): ChatMessage[] { return this.remotePageState.persistedMessages; } - get pendingMessages(): ChatMessage[] { return this.remotePageState.optimisticMessages; } - get activeTurnMessage(): ChatMessage { return this.remotePageState.activeTurnMessage; } - get timelineItems(): ChatTimelineItem[] { return this.remotePageState.timelineItems; } - get hasMoreMessages(): boolean { return this.remotePageState.hasMoreMessages; } - - async aboutToAppear(): Promise { - this.syncRemotePageSummary(); - await this.generalChatBootstrapController.restore(this.host.context()); - await this.cloudAccountSessionStore.init(this.host.context()); - await this.restoreCloudAccountSession(); - await this.refreshGeneralChatModelCatalog(); - await this.restoreIdentity(); - } - - onPageShow(): void { - RemoteLogger.info(`page show state=${this.connectionState} route=${this.currentRoute()}`); - this.resumeRemoteActivity(); - } - - onPageHide(): void { - RemoteLogger.info(`page hide state=${this.connectionState} route=${this.currentRoute()}`); - this.remoteActivityViewModel.invalidate(); - this.remoteConnectionCoordinator.invalidate(); - this.setRemoteBusy(false); - this.persistVisibleGeneralChatDraft(); - } - - aboutToDisappear(): void { - this.remoteActivityViewModel.invalidate(); - this.remoteConnectionCoordinator.invalidate(); - this.setRemoteBusy(false); - this.persistVisibleGeneralChatDraft(); - this.stopGeneralChatStream(true, 'failed'); - this.generalChatDraftLifecycleController.cancel(); - this.remoteFileDownloadController.cancel(); - this.remoteFilePreviewController.close(); - this.voiceInputLifecycleController.cancel(`${this.currentRoute()}`, () => { - this.setAllVoiceListening(false); - }); - } - - currentRoute(): AppRoute { - return this.appShellViewModel.currentRoute(); - } - - isGeneralComposerRoute(route: AppRoute): boolean { - return AppRootRouteState.isGeneralComposerRoute(route); - } - - visibleChatInput(): string { - if (this.currentRoute() === AppRoute.RemoteCreate) { - return this.remoteCreateState.draft; - } - return AppRootRouteState.chatInput(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - visibleSelectedImages(): SelectedImageAttachment[] { - return AppRootRouteState.selectedImages(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - visibleVoiceListening(): boolean { - if (this.currentRoute() === AppRoute.RemoteCreate) { - return this.remoteCreateState.isVoiceListening; - } - return AppRootRouteState.voiceListening(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - setChatInputForRoute(route: AppRoute, value: string): void { - if (route === AppRoute.RemoteCreate) { - this.remoteCreateState.setDraft(value); - return; - } - AppRootRouteState.setChatInput(route, value, this.generalChatPageState, this.remotePageState); - } - - setSelectedImagesForRoute(route: AppRoute, images: SelectedImageAttachment[]): void { - AppRootRouteState.setSelectedImages(route, images, this.generalChatPageState, this.remotePageState); - } - - addSelectedImagesForRoute(route: AppRoute, images: SelectedImageAttachment[]): void { - AppRootRouteState.addSelectedImages(route, images, this.generalChatPageState, this.remotePageState); - } - - removeSelectedImageForRoute(route: AppRoute, imageId: string): void { - AppRootRouteState.removeSelectedImage(route, imageId, this.generalChatPageState, this.remotePageState); - } - - clearComposerForRoute(route: AppRoute): void { - AppRootRouteState.clearComposer(route, this.generalChatPageState, this.remotePageState); - } - - setVoiceListeningForRoute(route: AppRoute, isVoiceListening: boolean): void { - if (route === AppRoute.RemoteCreate) { - this.remoteCreateState.isVoiceListening = isVoiceListening; - return; - } - AppRootRouteState.setVoiceListening( - route, - isVoiceListening, - this.generalChatPageState, - this.remotePageState - ); - } - - setAllVoiceListening(isVoiceListening: boolean): void { - this.generalChatPageState.setVoiceListening(isVoiceListening); - this.remotePageState.setVoiceListening(isVoiceListening); - } - - voiceInputSnapshot(route: AppRoute = this.currentRoute()): VoiceInputRouteSnapshot { - if (route === AppRoute.RemoteCreate) { - return { - routeId: `${route}`, - isListening: this.remoteCreateState.isVoiceListening, - isBusy: this.remoteCreateState.isSubmitting, - inputText: this.remoteCreateState.draft, - selectedImageCount: 0 - }; - } - return AppRootRouteState.snapshot( - route, - this.visibleChatBusy(), - this.generalChatPageState, - this.remotePageState - ); - } - - isRoute(route: AppRoute): boolean { - return this.appShellViewModel.isRoute(route); - } - - isGeneralChatVisible(): boolean { - return this.appShellViewModel.isGeneralChatVisible(); - } - - pushRoute(route: AppRoute, sessionId: string = ''): void { - this.appShellViewModel.pushRoute(route, sessionId); - } - - replaceRoute(route: AppRoute, sessionId: string = ''): void { - this.appShellViewModel.replaceRoute(route, sessionId); - } - - popRoute(fallback: AppRoute): void { - this.appShellViewModel.popRoute(fallback); - } - - private routeCreatedRemoteSession(sessionId: string): void { - this.closeFilePreview(); - if (this.isRoute(AppRoute.RemoteCreate)) { - this.appShellViewModel.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); - return; - } - this.pushRoute(AppRoute.RemoteChat, sessionId); - } - - private routeRemoteSessionInPlace(_sessionId: string): void { - this.closeFilePreview(); - if (this.isRoute(AppRoute.RemoteHome) || this.isRoute(AppRoute.RemoteChat)) { - return; - } - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - } - - private isRemoteConversationContext(sessionId: string): boolean { - if (sessionId.length === 0 || this.activeSession.sessionId !== sessionId) { - return false; - } - return this.isRoute(AppRoute.RemoteChat) || this.isRoute(AppRoute.RemoteHome); - } - - handleNavigationBack(route: AppRoute): boolean { - if (this.filePreviewState.visible) { - this.closeFilePreview(); - return true; - } - const action = this.appShellViewModel.backAction(route); - if (action === AppNavigationBackAction.CloseSidebar) { - this.closeAppSidebar(); - return true; - } - if (action === AppNavigationBackAction.CloseActiveChat) { - this.closeActiveChat(); - return true; - } - if (action === AppNavigationBackAction.PopRemoteHome) { - this.popRoute(AppRoute.ChatHome); - return true; - } - return false; - } - - handleRootBack(): boolean { - if (!this.filePreviewState.visible) { - return false; - } - this.closeFilePreview(); - return true; - } - - - handleConversationIntent(route: AppRoute, intent: ConversationIntent): void { - this.conversationIntentDispatcher.dispatch(route, intent); - } - - - async saveGeneralChatConfig( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ): Promise { - const update: GeneralChatConfigUpdate = { - apiUrl, - apiKey, - modelName, - clearApiKey - }; - try { - const validationError = await this.validateGeneralChatConfig(update); - if (validationError.length > 0) { - return validationError; - } - if (!update.clearApiKey) { - const probeError = await this.probeGeneralChatConfig(update); - if (probeError.length > 0) { - return probeError; - } - } - const catalogBeforeSave = await this.generalChatConfigStore.modelCatalog(); - const snapshot = await this.generalChatConfigStore.save(update); - if (GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel(catalogBeforeSave)) { - await this.generalChatConfigStore.selectLocalModel(); - } - this.applyGeneralChatConfig(snapshot); - await this.refreshGeneralChatModelCatalog(); - return ''; - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - async testGeneralChatConfig( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ): Promise { - const update: GeneralChatConfigUpdate = { - apiUrl, - apiKey, - modelName, - clearApiKey - }; - try { - const validationError = await this.validateGeneralChatConfig(update); - if (validationError.length > 0) { - return validationError; - } - if (update.clearApiKey) { - return RemoteI18n.t('settings.modelService.testNeedsKey'); - } - return await this.probeGeneralChatConfig(update); - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - private async validateGeneralChatConfig(update: GeneralChatConfigUpdate): Promise { - const snapshot = await this.generalChatConfigStore.snapshot(); - return GeneralChatConfigValidator.validate(update, snapshot.hasApiKey); - } - - private async probeGeneralChatConfig(update: GeneralChatConfigUpdate): Promise { - const apiKey = await this.effectiveGeneralChatApiKey(update); - if (apiKey.length === 0) { - return RemoteI18n.t('settings.modelService.apiKeyRequired'); - } - try { - await ModelProviderGeneralChatAdapter.probeConfiguration(update.apiUrl, apiKey, update.modelName); - return ''; - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - private async effectiveGeneralChatApiKey(update: GeneralChatConfigUpdate): Promise { - const directKey = update.apiKey.trim(); - if (directKey.length > 0) { - return directKey; - } - if (update.clearApiKey) { - return ''; - } - return (await this.generalChatConfigStore.accessToken()).trim(); - } - - applyGeneralChatConfig(snapshot: GeneralChatConfigSnapshot): void { - this.generalChatPageState.setConfiguration( - snapshot.apiUrl, - snapshot.modelName, - snapshot.hasApiKey, - GeneralChatServiceStatus.fromConfiguration(snapshot.apiUrl, snapshot.modelName, snapshot.hasApiKey) - ); - } - - private async refreshGeneralChatModelCatalog(): Promise { - const catalog = await this.generalChatConfigStore.modelCatalog(); - const selectedModelId = catalog.session_model_id || catalog.default_models.primary || ''; - this.generalChatPageState.setModelCatalog(catalog, selectedModelId); - const active = await this.generalChatConfigStore.activeSnapshot(); - this.generalChatPageState.setServiceState( - GeneralChatServiceStatus.fromConfiguration(active.apiUrl, active.modelName, active.hasApiKey) - ); - } - - async restoreIdentity(): Promise { - if (this.remotePageState.controlTargetType === 'account_device') { - return; - } - await this.remoteConnectionViewModel.restore(this.host.context()); - } - - async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { - await this.remoteConnectionViewModel.connect(autoReconnect, accountPassword); - await this.persistDelegatedAccountSession(); - } - - private async persistDelegatedAccountSession(): Promise { - if (this.cloudAccountSession) { - return; - } - const delegated = this.sessionManager.delegatedAccountSession(); - if (!delegated) { - return; - } - this.cloudAccountSession = delegated.session; - this.cloudAccountRelayUrl = delegated.relayUrl; - await this.cloudAccountSessionStore.save({ - relayUrl: delegated.relayUrl, - username: delegated.session.userId, - token: delegated.session.token, - userId: delegated.session.userId, - masterKey: Encoding.bytesToBase64(delegated.session.masterKey) - }); - this.remotePageState.setAccountUserId(delegated.session.userId); - this.remotePageState.setAccountUsername(delegated.session.userId); - RemoteLogger.info('delegated account session persisted after room pairing'); - } - - async reconnect(): Promise { - if (this.remotePageState.controlTargetType === 'account_device') { - await this.restoreCloudTarget( - this.remotePageState.controlTargetDeviceId, - this.remotePageState.controlTargetDeviceName - ); - return; - } - await this.remoteConnectionViewModel.reconnect(); - } - - async disconnect(clearPairing: boolean): Promise { - this.invalidateFilePreviewTarget(); - await this.remoteConnectionViewModel.disconnect(clearPairing); - } - - async pasteRemoteUrl(): Promise { - await this.remoteConnectionViewModel.paste(); - } - - async scanRemoteUrl(): Promise { - await this.remoteConnectionViewModel.scan(this.host.context()); - } - - handleDetectedRemoteUrl(remoteUrl: string): boolean { - return this.remoteConnectionViewModel.handleDetectedUrl(remoteUrl); - } - - applyWorkspace(workspace: WorkspaceInfo): void { - this.remoteConnectionViewModel.applyWorkspace(workspace); - } - - applyRemotePairingProjection(remoteUrl: string): void { - this.remoteConnectionViewModel.projectRemoteUrl(remoteUrl); - } - - ensureRemoteAvailable(): boolean { - return this.remoteConnectionViewModel.ensureAvailable(); - } - - setRemoteConnectionState(connectionState: ConnectionState): void { - this.remotePageState.setConnectionState(connectionState); - } - - setRemoteUrl(remoteUrl: string): void { - this.remotePageState.setRemoteUrl(remoteUrl); - } - - setRemoteUserId(userId: string): void { - this.remotePageState.setUserId(userId); - } - - setRemoteAuthenticatedUserId(authenticatedUserId: string): void { - this.remotePageState.setAuthenticatedUserId(authenticatedUserId); - } - - setRemoteStatusText(statusText: string): void { - this.remotePageState.setStatusText(statusText); - } - - setRemoteConnectionFailureKind(connectionFailureKind: string): void { - this.remotePageState.setConnectionFailureKind(connectionFailureKind); - } - - setRemoteBusy(isBusy: boolean): void { - this.remotePageState.setBusy(isBusy); - } - - setRemoteUrlInputVisible(visible: boolean): void { - this.remotePageState.setRemoteUrlInputVisible(visible); - } - - syncRemotePageSummary(): void { - if (this.remotePageState.statusText.length === 0) { - this.remotePageState.setStatusText(RemoteI18n.t('status.waitingConnection')); - } - if (this.remotePageState.workspaceName.length === 0) { - this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); - } - } - - failRemoteConnection(err: Object): void { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - this.setRemoteConnectionState(ConnectionState.Failed); - this.stopHeartbeat(); - } - - async showRecentWorkspaces(): Promise { - await this.remoteWorkspaceViewModel.toggleRecentWorkspaces(); - } - - async showAssistants(): Promise { - await this.remoteWorkspaceViewModel.toggleAssistants(); - } - - async selectWorkspace(path: string): Promise { - this.closeFilePreview(); - await this.remoteWorkspaceViewModel.selectWorkspace(path); - } - - async selectAssistant(path: string): Promise { - this.closeFilePreview(); - await this.remoteWorkspaceViewModel.selectAssistant(path); - } - - async refreshSessions(): Promise { - await this.remoteSessionViewModel.refreshSessions(); - } - - async loadMoreSessions(): Promise { - await this.remoteSessionViewModel.loadMoreSessions(); - } - - setSessionFilter(filter: string): void { - this.remoteSessionViewModel.setFilter(filter); - } - - visibleChatBusy(): boolean { - return this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat) ? - this.generalChatPageState.isBusy : this.remotePageState.isBusy; - } - - visibleStatusText(): string { - return this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat) ? - this.generalChatPageState.statusText : this.remotePageState.statusText; - } - - setVisibleStatusText(statusText: string): void { - if (this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat)) { - this.generalChatPageState.setStatus(statusText); - return; - } - this.setRemoteStatusText(statusText); - } - - openAppSidebar(): void { - this.host.animate(230, () => { - this.appShellState.setSidebarVisible(true); - }); - } - - closeAppSidebar(): void { - this.host.animate(210, () => { - this.appShellState.setSidebarVisible(false); - }); - } - - enterCodeEntry(): void { - if (this.cloudAccountSession && this.remotePageState.accountUserId.trim().length > 0) { - this.appShellState.setConnectSheetVisible(true); - return; - } - if (RemoteUiState.canUseRemote(this.connectionState)) { - this.appShellState.setConnectSheetVisible(false); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - return; - } - this.appShellState.setConnectSheetVisible(true); - } - - async switchWideConversationSource(source: ConversationSource): Promise { - if (AppRouteContract.conversationSource(this.currentRoute()) === source) { - return; - } - if (this.visibleVoiceListening()) { - await this.stopVoiceInput(false); - } - if (source === ConversationSource.General) { - this.stopPolling(); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - return; - } - this.persistVisibleGeneralChatDraft(); - const activeRemoteSessionId = this.remotePageState.activeSession.sessionId || ''; - const target = AppRouteContract.routeForConversationSource( - source, - RemoteUiState.canUseRemote(this.connectionState), - activeRemoteSessionId - ); - const hasActiveRemoteConversation = target.name === AppRoute.RemoteChat; - this.appShellViewModel.replaceRouteWithoutAnimation( - hasActiveRemoteConversation ? AppRoute.RemoteHome : target.name - ); - if (hasActiveRemoteConversation) { - this.startPolling(); - await this.loadActiveMessages(); - } - } - - /** - * Compact counterpart of switchWideConversationSource. Switching source is a - * change of context, not a command to start something: it resumes the session - * the user was last in, and otherwise rests on the Remote landing surface - * rather than opening the create composer for them. - */ - async switchCompactConversationSource(source: ConversationSource): Promise { - this.closeAppSidebar(); - if (AppRouteContract.conversationSource(this.currentRoute()) === source) { - return; - } - if (this.visibleVoiceListening()) { - await this.stopVoiceInput(false); - } - if (source === ConversationSource.General) { - this.stopPolling(); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - return; - } - this.persistVisibleGeneralChatDraft(); - const activeRemoteSessionId = RemoteUiState.canUseRemote(this.connectionState) ? - (this.remotePageState.activeSession.sessionId || '') : ''; - if (activeRemoteSessionId.length === 0) { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - return; - } - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteChat, activeRemoteSessionId); - this.startPolling(); - await this.loadActiveMessages(); - } - - private enterCompactLayout(): void { - const sessionId = this.remotePageState.activeSession.sessionId || ''; - if (this.isRoute(AppRoute.RemoteHome) && sessionId.length > 0) { - this.appShellViewModel.pushRoute(AppRoute.RemoteChat, sessionId, false); - } - } - - openAddConnection(): void { - this.appShellState.setConnectSheetVisible(true); - } - - openRemoteControlSettings(): void { - setTimeout(() => { - this.appShellState.openSettings('remote'); - }, 180); - } - - openAddConnectionFromSettings(): void { - this.appShellState.setSettingsVisible(false); - setTimeout(() => { - this.openAddConnection(); - }, 220); - } - - async loginCloudAccount(relayUrl: string, username: string, password: string): Promise { - RemoteLogger.info('cloud account UI login requested'); - const session = await this.cloudAccountClient.login(relayUrl, username, password, this.identityStoreSnapshotInstallId()); - this.applyCloudAccountSession(session, relayUrl, username); - await this.cloudAccountSessionStore.save({ - relayUrl: relayUrl.trim(), username: username.trim(), token: session.token, userId: session.userId, - masterKey: Encoding.bytesToBase64(session.masterKey) - }); - await this.loadGeneralChatAccountModels(session, relayUrl); - RemoteLogger.info('cloud account credentials persisted, refreshing account devices'); - RemoteLogger.info(`cloud account login success user=${session.userId}`); - return session.userId; - } - - private async restoreCloudAccountSession(): Promise { - try { - const persisted = await this.cloudAccountSessionStore.load(); - if (!persisted) return; - const session: CloudAccountSession = { - token: persisted.token, - userId: persisted.userId, - masterKey: Encoding.base64ToBytes(persisted.masterKey) - }; - this.applyCloudAccountSession(session, persisted.relayUrl, persisted.username || session.userId); - await this.loadGeneralChatAccountModels(session, persisted.relayUrl); - } catch (err) { - RemoteLogger.warn(`cloud account restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); - await this.cloudAccountSessionStore.clear(); - } - } - - private async loadGeneralChatAccountModels(session: CloudAccountSession, relayUrl: string): Promise { - this.generalChatConfigStore.replaceAccountModels([]); - try { - const blob = await this.cloudAccountClient.fetchSettings(relayUrl, session); - if (!blob) { - this.generalChatConfigStore.replaceAccountModels([]); - await this.refreshGeneralChatModelCatalog(); - RemoteLogger.info('cloud model catalog is empty'); - return; - } - const models = GeneralChatCloudConfigPolicy.models(blob.plaintext); - this.generalChatConfigStore.replaceAccountModels(models); - await this.refreshGeneralChatModelCatalog(); - RemoteLogger.info(`cloud model catalog loaded count=${models.length} version=${blob.version}`); - } catch (err) { - await this.refreshGeneralChatModelCatalog(); - RemoteLogger.warn(`cloud model catalog load failed: ${err instanceof Error ? err.message : 'unknown error'}`); - } - } - - async syncCloudAccount(): Promise { - if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { - throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); - } - let bundles: Object[]; - try { - bundles = await this.cloudAccountClient.fetchSessions(this.cloudAccountRelayUrl, this.cloudAccountSession, 0); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 401) { - await this.expireCloudAccountSession(); - throw new Error(RemoteI18n.t('remote.settings.accountExpired')); - } - throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountSyncFailed')); - } - await this.loadGeneralChatAccountModels(this.cloudAccountSession, this.cloudAccountRelayUrl); - RemoteLogger.info(`cloud account backup sync completed count=${bundles.length}`); - return String(bundles.length); - } - - protected applyCloudAccountSession(session: CloudAccountSession, relayUrl: string, username: string): void { - this.cloudAccountSession = session; - this.cloudAccountRelayUrl = relayUrl.trim(); - this.remotePageState.setAccountUserId(session.userId); - this.remotePageState.setAccountUsername(username.trim()); - } - - async logoutCloudAccount(): Promise { - this.invalidateFilePreviewTarget(); - if (this.remotePageState.controlTargetType === 'account_device') { - this.remoteActivityViewModel.invalidate(); - this.stopPolling(); - this.stopHeartbeat(); - this.sessionManager.reset(); - this.remotePageState.clearActiveSession(); - this.remotePageState.setSessions([], false); - this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); - this.remotePageState.setConnectionState(ConnectionState.Disconnected); - this.remotePageState.setAuthenticatedUserId(''); - } - this.cloudAccountSession = undefined; - this.cloudAccountRelayUrl = ''; - this.generalChatConfigStore.replaceAccountModels([]); - await this.refreshGeneralChatModelCatalog(); - await this.cloudAccountSessionStore.clear(); - this.remotePageState.setAccountUserId(''); - this.remotePageState.setAccountUsername(''); - this.remotePageState.clearControlTarget(); - RemoteLogger.info('cloud account logout success'); - } - - async listCloudAccountDevices(): Promise { - if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { - return []; - } - try { - return await this.cloudAccountClient.listDevices(this.cloudAccountRelayUrl, this.cloudAccountSession); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 401) { - await this.expireCloudAccountSession(); - throw new Error(RemoteI18n.t('remote.settings.accountExpired')); - } - if (err instanceof CloudAccountRequestError && - (err.statusCode === 404 || err.statusCode === 503 || err.statusCode === 504)) { - throw new Error(RemoteI18n.t('remote.settings.deviceUnavailable')); - } - throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.deviceLoadFailed')); - } - } - - async getRemotePermissionMode(): Promise { - if (!this.ensureRemoteAvailable()) { - throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); - } - return this.sessionManager.getPermissionMode(); - } - - async setRemotePermissionMode(mode: RemotePermissionMode): Promise { - if (!this.ensureRemoteAvailable()) { - throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); - } - return this.sessionManager.setPermissionMode(mode); - } - - private async restoreCloudTarget(targetDeviceId: string, targetDeviceName: string): Promise { - const targetId = targetDeviceId.trim(); - if (targetId.length === 0) { - return; - } - try { - const devices = await this.listCloudAccountDevices(); - const target = devices.find((device: CloudAccountDevice): boolean => device.deviceId === targetId); - if (!target || !target.online) { - const targetName = target?.deviceName || targetDeviceName || targetId; - this.remotePageState.setControlTarget('account_device', targetId, targetName); - this.remotePageState.setDesktopIdentity(targetName, targetId); - this.remotePageState.setConnectionState(ConnectionState.Failed); - this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.deviceUnavailable')); - return; - } - await this.selectCloudAccountDevice({ - deviceId: target.deviceId, - deviceName: target.deviceName || targetDeviceName || target.deviceId, - online: target.online, - lastSeenAt: target.lastSeenAt - }); - } catch (err) { - RemoteLogger.warn(`cloud target restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); - } - } - - private async expireCloudAccountSession(): Promise { - this.invalidateFilePreviewTarget(); - this.cloudAccountSession = undefined; - this.cloudAccountRelayUrl = ''; - await this.cloudAccountSessionStore.clear(); - this.remotePageState.setAccountUserId(''); - this.remotePageState.setAccountUsername(''); - if (this.remotePageState.controlTargetType === 'account_device') { - this.remoteActivityViewModel.invalidate(); - this.stopPolling(); - this.stopHeartbeat(); - this.sessionManager.reset(); - this.remotePageState.clearActiveSession(); - this.remotePageState.setSessions([], false); - this.remotePageState.clearControlTarget(); - this.remotePageState.setConnectionState(ConnectionState.Disconnected); - } - } - - private async handleRemoteConnectionError(err: Object): Promise { - if (this.remotePageState.controlTargetType !== 'account_device' || - !(err instanceof CloudAccountRequestError) || err.statusCode !== 401) { - return false; - } - await this.expireCloudAccountSession(); - this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.accountExpired')); - return true; - } - - async selectCloudAccountDevice(device: CloudAccountDevice, navigateHome: boolean = true): Promise { - if (!device.online) { - throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); - } - if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { - throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); - } - const deviceId = device.deviceId.trim(); - if (deviceId.length === 0 || deviceId === this.remoteConnectionViewModel.getDeviceId()) { - return; - } - if (deviceId === this.remotePageState.controlTargetDeviceId && - this.connectionState === ConnectionState.Connected) { - this.appShellState.setConnectSheetVisible(false); - if (navigateHome) { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - } - return; - } - this.invalidateFilePreviewTarget(); - this.remoteActivityViewModel.invalidate(); - this.remoteConnectionCoordinator.invalidate(); - this.stopPolling(); - this.stopHeartbeat(); - // Do not keep presenting the previous device while the new account device - // is being handshaken. Clear its projection before the async connect. - this.remotePageState.setConnectionState(ConnectionState.Reconnecting); - this.remotePageState.setLoadingHome(true); - this.remotePageState.clearControlTarget(); - this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); - this.remotePageState.setBusy(true); - this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.deviceConnecting')); - this.remotePageState.clearActiveSession(); - this.resetChatTimeline(''); - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - this.remotePageState.setSessions([], false); - try { - const initialSync = await this.sessionManager.connectAccountDevice( - this.cloudAccountClient, - this.cloudAccountRelayUrl, - this.cloudAccountSession, - deviceId - ); - this.remotePageState.setControlTarget('account_device', deviceId, device.deviceName); - this.remotePageState.setDesktopIdentity(device.deviceName, deviceId); - this.remotePageState.setWorkspace( - initialSync.workspace.name, - initialSync.workspace.path, - initialSync.workspace.assistantId || '', - initialSync.workspace.gitBranch, - initialSync.workspace.workspaceKind || 'normal' - ); - this.remotePageState.setSessions(initialSync.sessions, initialSync.hasMoreSessions); - this.remotePageState.setAuthenticatedUserId(initialSync.authenticatedUserId); - this.remotePageState.setConnectionState(ConnectionState.Connected); - this.remotePageState.setStatusText(RemoteI18n.t('connection.connected')); - this.appShellState.setSettingsVisible(false); - this.appShellState.setConnectSheetVisible(false); - if (navigateHome) { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - } - await this.cloudAccountSessionStore.save({ - relayUrl: this.cloudAccountRelayUrl, - username: this.remotePageState.accountUsername, - token: this.cloudAccountSession.token, - userId: this.cloudAccountSession.userId, - masterKey: Encoding.bytesToBase64(this.cloudAccountSession.masterKey), - targetDeviceId: deviceId, - targetDeviceName: device.deviceName - }); - this.startHeartbeat(); - await this.loadRecentWorkspacesInBackground(); - this.remotePageState.setLoadingHome(false); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 401) { - await this.expireCloudAccountSession(); - } - this.remotePageState.clearControlTarget(); - this.remotePageState.setConnectionState(ConnectionState.Failed); - const message = ConnectionErrorPolicy.errorText(err); - this.remotePageState.setStatusText(message); - this.sessionManager.reset(); - throw new Error(message); - } finally { - this.remotePageState.setLoadingHome(false); - this.remotePageState.setBusy(false); - } - } - - private identityStoreSnapshotInstallId(): string { - return this.remoteConnectionViewModel.getDeviceId(); - } - - openHomeSession(session: RemoteSession): void { - this.closeFilePreview(); - if (session.agentType === 'chat') { - this.openGeneralSession(session); - return; - } - this.openSession(session); - } - - openHomeSessionInPlace(session: RemoteSession): void { - this.closeFilePreview(); - if (session.agentType === 'chat') { - this.openGeneralSession(session); - return; - } - this.openSession(session, true); - } - - async deleteHomeSession(session: RemoteSession): Promise { - if (session.agentType !== 'chat') { - await this.deleteSession(session); - return; - } - await this.generalChatCommandController.deleteSession(session, this.generalChatPageState.isBusy); - } - - activeGeneralChatAsRemoteSession(): RemoteSession { - const active = this.generalChatPageState.activeSession; - return { - id: active.sessionId, - title: active.title, - agentType: 'chat', - status: 'ready', - updatedAt: '', - createdAt: '', - messageCount: this.generalChatPageState.timelineItems.length, - workspacePath: active.workspacePath - }; - } - - activeGeneralUploadedFileCount(): number { - let count = 0; - this.generalChatPageState.timelineItems.forEach((item: ChatTimelineItem) => { - if (item.message && item.message.images) { - count += item.message.images.length; - } - }); - return count; - } - - async archiveHomeSession(session: RemoteSession, archived: boolean): Promise { - await this.generalChatCommandController.archiveSession(session, archived, this.generalChatPageState.isBusy); - } - - async exportHomeSession(session: RemoteSession): Promise { - await this.generalChatCommandController.exportSession( - session, - this.generalChatPageState.isBusy, - async (text: string): Promise => { - await this.clipboardService.writeText(text); - } - ); - } - - async openGeneralSession(item: RemoteSession): Promise { - if (this.generalChatPageState.isBusy) { - return; - } - this.stopPolling(); - this.stopGeneralChatStream(false); - await this.generalChatCommandController.openSession( - item, - this.generalChatPageState.isBusy, - async (sessionId: string): Promise => { - return this.generalChatDraftLifecycleController.restore(sessionId); - }, - (_sessionId: string) => { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - ); - } - - async startGeneralChat(text: string): Promise { - const trimmed = text.trim(); - if (trimmed.length === 0 || this.generalChatPageState.isBusy) { - return; - } - this.stopPolling(); - this.stopGeneralChatStream(false); - this.generalChatDraftLifecycleController.cancel(); - const created = await this.generalChatCommandController.createSession( - trimmed, - this.generalChatPageState.isBusy, - async (): Promise => { - await this.generalChatDraftLifecycleController.clearHomeNow(); - }, - (_sessionId: string) => { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - ); - if (!created) { - return; - } - await this.sendGeneralChatMessage(); - } - - async sendVisibleChatMessage(): Promise { - if (this.isGeneralChatVisible()) { - if ((this.generalChatPageState.activeSession.sessionId || '').length === 0) { - this.startVisibleGeneralChat(); - return; - } - await this.sendGeneralChatMessage(); - return; - } - await this.sendChatMessage(); - } - - async stopActiveChatTask(): Promise { - if (this.isGeneralChatVisible()) { - this.stopGeneralChatStream(true); - return; - } - await this.stopActiveTask(); - } - - closeActiveChat(): void { - this.closeFilePreview(); - this.stopVoiceInput(false); - if (this.isRoute(AppRoute.GeneralChat)) { - this.persistVisibleGeneralChatDraft(); - this.stopGeneralChatStream(true); - this.popRoute(AppRoute.ChatHome); - this.restoreGeneralChatDraft(GENERAL_CHAT_HOME_DRAFT_ID); - return; - } - this.stopPolling(); - this.popRoute(AppRoute.RemoteHome); - } - - async renameVisibleSession(title: string): Promise { - if (this.isGeneralChatVisible()) { - await this.generalChatCommandController.renameActiveSession( - this.generalChatPageState.activeSession, - title - ); - return; - } - await this.renameActiveSession(title); - } - - async retryVisibleMessage(text: string): Promise { - if (this.isGeneralChatVisible()) { - const sessionId = this.generalChatPageState.activeSession.sessionId || ''; - const prepared = await this.generalChatCommandController.retryMessage( - sessionId, - text, - this.generalChatPageState.isBusy - ); - if (prepared) { - await this.sendGeneralChatMessage(); - } - return; - } - this.retryMessage(text); - } - - downloadVisibleFile(path: string): void { - if (this.isGeneralChatVisible()) { - this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.fileDownloadMock')); - return; - } - this.downloadFile(path); - } - - openFilePreview(route: AppRoute, request: FilePreviewRequest): void { - const context = new FilePreviewTargetContext( - this.remotePageState.activeSession.sessionId, - this.remotePageState.activeSession.workspacePath || this.remotePageState.workspacePath, - this.controlTargetEpoch - ); - const resolution = FileTargetResolver.resolve(request.reference, request.label, context); - if (resolution.kind === FileReferenceKind.HttpUrl) { - void this.openExternalLink(route, request.reference); - return; - } - if (route !== AppRoute.RemoteChat) { - this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.filePreviewUnavailable')); - return; - } - if (resolution.kind !== FileReferenceKind.RemoteWorkspaceFile || !resolution.target) { - return; - } - void this.remoteFilePreviewController.open(resolution.target); - } - - private async openExternalLink(route: AppRoute, reference: string): Promise { - const opened = this.host.openExternalLink ? await this.host.openExternalLink(reference) : false; - if (!opened) { - if (AppRouteContract.isGeneralComposerRoute(route)) { - this.generalChatPageState.setStatus(RemoteI18n.t('errors.operationFailed')); - } else { - this.setRemoteStatusText(RemoteI18n.t('errors.operationFailed')); - } - } - } - - closeFilePreview(): void { - this.remoteFilePreviewController.close(); - } - - refreshFilePreview(): void { - void this.remoteFilePreviewController.refresh(); - } - - openFilePreviewLink(reference: string, label: string): void { - this.openFilePreview(AppRoute.RemoteChat, new FilePreviewRequest(reference, label)); - } - - invalidateFilePreviewTarget(): void { - this.controlTargetEpoch += 1; - this.remoteFilePreviewController.close(); - } - - async createSession(agentType: string, inPlace: boolean = false): Promise { - this.closeFilePreview(); - await this.remoteSessionViewModel.createSession( - agentType, - '', - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); - } - - openRemoteCreateSession(): void { - if (!this.ensureRemoteAvailable()) { - return; - } - const deviceId = this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId; - const deviceName = this.remotePageState.controlTargetDeviceName || this.remotePageState.desktopName; - this.remoteCreateState.prepare(deviceId, deviceName, this.remotePageState.selectedModelId); - if (deviceId.length > 0) { - this.remoteCreateState.setDevices([{ - deviceId, - deviceName: deviceName || deviceId, - online: true - }]); - } - this.remoteCreateState.setWorkspaces(this.remotePageState.recentWorkspaces); - this.pushRoute(AppRoute.RemoteCreate); - this.loadRemoteCreateChoices(); - this.loadRemoteCreateModelCatalog(); - } - - closeRemoteCreateSession(): void { - this.remoteCreateWorkspaceLoadVersion += 1; - this.stopVoiceInput(false); - this.remoteCreateState.closeMenu(); - this.popRoute(AppRoute.RemoteHome); - } - - async loadRemoteCreateChoices(): Promise { - await Promise.all([ - this.loadRemoteCreateDevices(), - this.loadRemoteCreateWorkspaces() - ]); - } - - async loadRemoteCreateModelCatalog(): Promise { - if (this.remotePageState.modelCatalog.models.length > 0) { - return; - } - try { - const catalog = await this.sessionManager.getModelCatalog(); - const selectedModelId = RemoteUiState.selectedModelIdForCatalog( - catalog, - this.remotePageState.selectedModelId - ); - this.remotePageState.setModelCatalog(catalog, selectedModelId); - this.remoteCreateState.setSelectedModelId(selectedModelId); - } catch (_err) { - // Model selection remains hidden when the remote does not expose a catalog. - } - } - - async loadRemoteCreateDevices(): Promise { - this.remoteCreateState.isLoadingDevices = this.remoteCreateState.devices.length === 0; - try { - const phoneDeviceId = this.remoteConnectionViewModel.getDeviceId(); - const accountDevices = await this.listCloudAccountDevices(); - const devices = accountDevices.filter((device: CloudAccountDevice): boolean => - device.online && device.deviceId !== phoneDeviceId - ); - const currentId = this.remoteCreateState.selectedDeviceId; - if (currentId.length > 0 && !devices.some((device: CloudAccountDevice): boolean => device.deviceId === currentId)) { - devices.unshift({ - deviceId: currentId, - deviceName: this.remoteCreateState.selectedDeviceName || currentId, - online: true - }); - } - this.remoteCreateState.setDevices(devices); - } catch (err) { - const currentId = this.remoteCreateState.selectedDeviceId; - if (currentId.length > 0) { - this.remoteCreateState.setDevices([{ - deviceId: currentId, - deviceName: this.remoteCreateState.selectedDeviceName || currentId, - online: true - }]); - } else { - this.remoteCreateState.setDevices([]); - } - this.remoteCreateState.errorText = RemoteI18n.t('remote.create.deviceLoadFailed'); - } - } - - async loadRemoteCreateWorkspaces(): Promise { - const loadVersion = ++this.remoteCreateWorkspaceLoadVersion; - const deviceId = this.remoteCreateState.selectedDeviceId; - this.remoteCreateState.isLoadingWorkspaces = this.remoteCreateState.workspaces.length === 0; - try { - const workspaces = await this.workspaceCoordinator.recentWorkspaces(); - if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || - deviceId !== this.remoteCreateState.selectedDeviceId) { - return; - } - this.remoteCreateState.setWorkspaces(workspaces); - } catch (err) { - if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || - deviceId !== this.remoteCreateState.selectedDeviceId) { - return; - } - this.remoteCreateState.setWorkspaces([]); - this.remoteCreateState.errorText = RemoteI18n.t('remote.create.workspaceLoadFailed'); - } - } - - toggleRemoteCreateDevices(): void { - this.remoteCreateState.toggleMenu('devices'); - if (this.remoteCreateState.openMenu === 'devices' && this.remoteCreateState.devices.length === 0) { - this.loadRemoteCreateDevices(); - } - } - - toggleRemoteCreateWorkspaces(): void { - this.remoteCreateState.toggleMenu('workspaces'); - if (this.remoteCreateState.openMenu === 'workspaces' && this.remoteCreateState.workspaces.length === 0) { - this.loadRemoteCreateWorkspaces(); - } - } - - async selectRemoteCreateDevice(device: CloudAccountDevice): Promise { - if (device.deviceId === this.remoteCreateState.selectedDeviceId) { - this.remoteCreateState.closeMenu(); - return; - } - const draft = this.remoteCreateState.draft; - this.remoteCreateState.closeMenu(); - this.remoteCreateState.isLoadingWorkspaces = true; - try { - await this.selectCloudAccountDevice(device, false); - this.remoteCreateState.selectDevice(device); - this.remoteCreateState.setDraft(draft); - await this.loadRemoteCreateWorkspaces(); - } catch (err) { - this.remoteCreateState.isLoadingWorkspaces = false; - this.remoteCreateState.errorText = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceSwitchFailed'); - } - } - - selectRemoteCreateWorkspace(path: string): void { - const workspace = this.remoteCreateState.workspaces.find((item: RecentWorkspaceEntry): boolean => item.path === path); - this.remoteCreateState.selectWorkspace(workspace); - } - - selectRemoteCreateModel(modelId: string): void { - this.remoteCreateState.setSelectedModelId(modelId); - } - - async submitRemoteCreateSession(): Promise { - const instruction = this.remoteCreateState.draft.trim(); - if (instruction.length === 0 || this.remoteCreateState.isSubmitting || !this.ensureRemoteAvailable()) { - return; - } - const context = this.remoteCreateState.submissionContext(); - const activeDeviceId = this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId; - if (context.deviceId.length === 0 || context.deviceId !== activeDeviceId) { - this.remoteCreateState.errorText = RemoteI18n.t('remote.create.deviceMismatch'); - return; - } - this.remoteCreateState.isSubmitting = true; - this.remoteCreateState.errorText = ''; - this.remoteCreateState.closeMenu(); - try { - if (context.workspacePath.length > 0) { - await this.remoteSessionViewModel.createSessionInWorkspace( - context.workspacePath, - this.workspacePath, - instruction, - context.agentType, - undefined, - this.remoteCreateState.selectedModelId - ); - } else { - await this.remoteSessionViewModel.createSession( - context.agentType, - instruction, - undefined, - this.remoteCreateState.selectedModelId - ); - } - if (this.isRoute(AppRoute.RemoteCreate)) { - this.remoteCreateState.errorText = this.statusText || RemoteI18n.t('remote.create.submitFailed'); - } - } catch (err) { - this.remoteCreateState.errorText = err instanceof Error ? err.message : - RemoteI18n.t('remote.create.submitFailed'); - } finally { - this.remoteCreateState.isSubmitting = false; - } - } - - async createSessionInWorkspace( - path: string, - agentType: string = 'code', - inPlace: boolean = false - ): Promise { - this.closeFilePreview(); - await this.remoteSessionViewModel.createSessionInWorkspace( - path, - this.workspacePath, - '', - agentType, - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); - } - - applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void { - this.remoteWorkspaceSessions = all; - const current = this.remotePageState.sessions; - const extras = all.filter((item: RemoteSession) => item.workspacePath !== this.workspacePath); - this.remotePageState.setSessions(this.mergeSessions(current, extras), this.remotePageState.hasMoreSessions); - } - - async loadRecentWorkspacesInBackground(): Promise { - await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); - } - - mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[] { - const merged = primary.slice(); - extras.forEach((item: RemoteSession) => { - if (!merged.some((existing: RemoteSession) => existing.id === item.id)) { - merged.push(item); - } - }); - return merged; - } - - async openSession(item: RemoteSession, inPlace: boolean = false): Promise { - this.closeFilePreview(); - await this.remoteSessionViewModel.openSession( - item, - this.workspacePath, - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); - } - - applyRemoteActiveSession(session: SessionSummary): void { - const current = this.remotePageState.activeSession; - if (this.filePreviewState.visible && - (current.sessionId !== session.sessionId || current.workspacePath !== session.workspacePath)) { - this.closeFilePreview(); - } - this.remotePageState.setActiveSession(session); - } - - async deleteSession(item: RemoteSession): Promise { - await this.remoteSessionViewModel.deleteSession(item, this.workspacePath); - } - - async loadActiveMessages(): Promise { - await this.remoteSessionViewModel.loadActiveMessages((activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - }); - } - - async loadModelCatalog(sessionId: string): Promise { - await this.remoteSessionViewModel.loadModelCatalog(sessionId, (activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - }); - } - - async selectModel(modelId: string): Promise { - if (this.isGeneralChatVisible()) { - if (await this.generalChatConfigStore.selectModel(modelId)) { - await this.refreshGeneralChatModelCatalog(); - } - return; - } - await this.remoteSessionViewModel.selectModel(modelId); - } - - async loadOlderMessages(): Promise { - await this.remoteSessionViewModel.loadOlderMessages(this.knownPollVersion); - } - - async sendGeneralChatMessage(): Promise { - await this.generalChatConversationViewModel.sendMessage(); - return; - } - - stopGeneralChatStream(cancelled: boolean, finalStatus: string = 'cancelled'): void { - this.generalChatConversationViewModel.stop(cancelled, finalStatus); - return; - } - - async sendChatMessage(): Promise { - if (this.remotePageState.isVoiceListening) { - await this.stopVoiceInput(false); - } - const rawText = this.remotePageState.chatInput.trim(); - const images = this.remotePageState.selectedImages.slice(); - const text = rawText.length > 0 ? rawText : (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - const sessionId = this.activeSession.sessionId || ''; - if ((!text && images.length === 0) || !sessionId || this.isBusy) { - return; - } - if (!this.ensureRemoteAvailable()) { - return; - } - this.remotePageState.clearComposer(); - const localMessage = RemoteUiState.localUserMessage(text, images); - this.chatTimelineStore.appendOptimisticMessage(localMessage); - const pendingActiveId = this.chatTimelineStore.setPendingActiveTurn(localMessage.id); - this.syncChatTimelineFromStore(); - RemoteLogger.info(`chat send queued session=${this.shortSessionId(sessionId)} pending=${pendingActiveId}`); - this.startPolling(); - this.nudgeChatPolling(); - const imageContexts: RemoteImageContext[] = images.length > 0 ? this.imagePickerService.toRemoteContexts(images) : []; - await this.remoteChatCommandController.sendPreparedMessage( - sessionId, - text, - this.activeSession.agentType, - rawText, - images, - imageContexts, - localMessage.id, - pendingActiveId, - this.isBusy, - true - ); - } - - async toggleVoiceInput(): Promise { - const route = this.currentRoute(); - await this.voiceInputLifecycleController.toggle(this.host.context(), this.voiceInputSnapshot(route)); - } - - async stopVoiceInput(showStatus: boolean): Promise { - const route = this.currentRoute(); - await this.voiceInputLifecycleController.stop(this.voiceInputSnapshot(route), showStatus); - } - - showVoiceInputError(message: string): void { - const text = message.length > 0 ? message : RemoteI18n.t('errors.voiceInputUnavailable'); - this.setVisibleStatusText(text); - this.host.showToast(text, 2600); - } - - async pickImages(): Promise { - if (this.visibleChatBusy()) { - return; - } - const route = this.currentRoute(); - if (this.visibleVoiceListening()) { - await this.stopVoiceInput(false); - } - try { - this.setVisibleStatusText(RemoteI18n.t('status.pickImage')); - const picked = await this.imagePickerService.pickImages(3, this.visibleSelectedImages().length); - if (picked.length === 0) { - this.setVisibleStatusText(RemoteI18n.t('status.noImageSelected')); - return; - } - this.addSelectedImagesForRoute(route, picked); - this.setVisibleStatusText(RemoteI18n.f('status.imagesSelected', `${this.visibleSelectedImages().length}`)); - } catch (err) { - this.setVisibleStatusText(ConnectionErrorPolicy.errorText(err)); - } - } - - removeSelectedImage(imageId: string): void { - this.removeSelectedImageForRoute(this.currentRoute(), imageId); - } - - startVisibleGeneralChat(): void { - const rawText = this.generalChatPageState.chatInput.trim(); - const text = rawText.length > 0 ? rawText : - (this.generalChatPageState.selectedImages.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - if (text.length === 0 || this.generalChatPageState.isBusy) { - return; - } - if (this.generalChatPageState.serviceState === GeneralChatServiceState.Unconfigured) { - const statusText = GeneralChatServiceStatus.userMessage(this.generalChatPageState.serviceState); - this.generalChatPageState.setStatus(statusText); - this.showHomeToast(statusText); - return; - } - this.startGeneralChat(text); - } - - generalChatHomeStatusText(): string { - if (this.generalChatPageState.serviceState === GeneralChatServiceState.Ready || - this.generalChatPageState.serviceState === GeneralChatServiceState.Sending || - this.generalChatPageState.serviceState === GeneralChatServiceState.Streaming) { - return ''; - } - return GeneralChatServiceStatus.userMessage( - this.generalChatPageState.serviceState, - this.generalChatPageState.statusText - ); - } - - prepareNewGeneralChat(): void { - this.stopVoiceInput(false); - this.stopGeneralChatStream(true); - this.generalChatDraftLifecycleController.clearHome(); - this.generalChatPageState.clearComposer(); - this.generalChatPageState.clearActiveSession(); - this.resetGeneralChatTimeline(''); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - - onVisibleChatInputChange(route: AppRoute, value: string): void { - this.setChatInputForRoute(route, value); - if (!this.isGeneralComposerRoute(route)) { - return; - } - this.generalChatDraftLifecycleController.scheduleVisible(value); - } - - visibleGeneralChatDraftId(): string { - if (this.isGeneralChatVisible()) { - return this.generalChatPageState.activeSession.sessionId || GENERAL_CHAT_HOME_DRAFT_ID; - } - return ''; - } - - persistVisibleGeneralChatDraft(): void { - this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); - } - - async restoreGeneralChatDraft(draftId: string): Promise { - this.generalChatPageState.setChatInput(await this.generalChatDraftLifecycleController.restore(draftId)); - } - - latestUserMessageText(): string { - if (this.isGeneralChatVisible()) { - return this.generalChatPageState.latestUserMessageText(); - } - const candidates = this.messages.concat(this.pendingMessages); - for (let index = candidates.length - 1; index >= 0; index--) { - if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { - return candidates[index].text; - } - } - return ''; - } - - showHomeToast(message: string): void { - if (!this.host.showToast(message, 2600)) { - this.setVisibleStatusText(message); - } - } - - async stopActiveTask(): Promise { - const sessionId = this.activeSession.sessionId || ''; - if (!sessionId) { - return; - } - await this.remoteChatCommandController.stopTask( - sessionId, - this.activeTurnMessage.id, - this.currentActiveTurnId(), - this.ensureRemoteAvailable() - ); - } - - async renameActiveSession(title: string): Promise { - const nextTitle = title.trim(); - if ( - !this.activeSession.sessionId || - nextTitle.length === 0 || - nextTitle === this.activeSession.title || - this.isBusy - ) { - return; - } - await this.remoteChatCommandController.renameActiveSession( - this.activeSession, - nextTitle, - this.isBusy, - this.ensureRemoteAvailable() - ); - } - - async copyMessage(text: string): Promise { - if (text.trim().length === 0) { - return; - } - try { - await this.clipboardService.writeText(text); - this.setRemoteStatusText(RemoteI18n.t('status.messageCopied')); - } catch (err) { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - } - } - - async downloadFile(path: string): Promise { - const sessionId = this.activeSession.sessionId || ''; - await this.remoteFileDownloadController.download(path, sessionId, this.isBusy, this.ensureRemoteAvailable()); - } - - retryMessage(text: string): void { - if (this.isBusy) { - return; - } - if (!this.ensureRemoteAvailable()) { - return; - } - this.remotePageState.setChatInput(text); - this.sendChatMessage(); - } - - async approveTool(toolId: string, updatedInput?: Object): Promise { - await this.remoteToolActionController.approve( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable(), - updatedInput - ); - } - - async rejectTool(toolId: string): Promise { - await this.remoteToolActionController.reject( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable() - ); - } - - async cancelTool(toolId: string): Promise { - await this.remoteToolActionController.cancel( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable() - ); - } - - async answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { - await this.remoteToolActionController.answer( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable(), - answers - ); - } - - resetChatTimeline(sessionId: string): void { - this.chatTimelineStore.reset(sessionId); - this.knownPollVersion = 0; - this.syncChatTimelineFromStore(); - } - - resetGeneralChatTimeline(sessionId: string): void { - this.chatTimelineStore.reset(sessionId); - this.syncGeneralChatTimelineFromStore(); - } - - syncChatTimelineFromStore(): void { - const state: ChatTimelineState = this.chatTimelineStore.snapshotState(); - const projectedItems: ChatTimelineItem[] = this.projectedTimelineItems(); - this.remotePageState.setTimelineProjection( - state.persistedMessages, - state.optimisticMessages, - state.activeTurn || RemoteUiState.emptyActiveTurn(), - this.hasMoreMessages, - projectedItems - ); - this.remotePageState.setModelCatalog(state.modelCatalog, state.selectedModelId); - } - - syncGeneralChatTimelineFromStore(): void { - const state: ChatTimelineState = this.chatTimelineStore.snapshotState(); - const projectedItems = this.chatTimelineStore.viewState(false); - this.generalChatPageState.setTimelineProjection( - state.persistedMessages, - state.optimisticMessages, - state.activeTurn || RemoteUiState.emptyActiveTurn(), - false, - projectedItems - ); - const itemSummary = projectedItems.map((item: ChatTimelineItem) => { - const message = item.message; - return `${item.type}:${item.id}:${message ? message.status : ''}:${message ? message.text.length : 0}`; - }).join(','); - RemoteLogger.info(`general chat projection revision=${this.generalChatPageState.timelineRevision} persisted=${state.persistedMessages.length} active=${state.activeTurn ? state.activeTurn.id : 'none'} items=${itemSummary}`); - } - - startPolling(): void { - this.remoteChatPollingLifecycleController.startActiveSession({ - sessionId: this.activeSession.sessionId || '', - cursor: this.currentChatPollingCursor(), - activeTurn: this.activeTurnMessage - }); - } - - stopPolling(): void { - this.remoteChatPollingLifecycleController.stop(); - } - - nudgeChatPolling(): void { - this.remoteChatPollingLifecycleController.nudge(); - } - - async pollActiveSession(): Promise { - await this.remoteChatPollingLifecycleController.pollNow(); - } - - currentChatPollingCursor(): RemoteChatPollingCursor { - return { - pollVersion: this.knownPollVersion, - knownMessageCount: this.knownRemoteMessageCount, - knownModelCatalogVersion: this.knownModelCatalogVersion - }; - } - - updateChatPollingCursor(pollVersion: number, knownMessageCount: number): void { - this.knownPollVersion = pollVersion; - this.knownRemoteMessageCount = knownMessageCount; - this.remoteChatPollingLifecycleController.updateCursor({ - pollVersion, - knownMessageCount, - knownModelCatalogVersion: this.knownModelCatalogVersion - }); - } - - applyChatSessionSnapshot(snapshot: RemoteChatPollingSnapshot): void { - if (!this.isRemoteConversationContext(snapshot.sessionId)) { - return; - } - this.chatTimelineStore.applySnapshot(snapshot); - this.syncChatTimelineFromStore(); - this.knownPollVersion = snapshot.cursor.pollVersion; - this.knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion; - this.knownRemoteMessageCount = snapshot.cursor.knownMessageCount; - if (snapshot.title.length > 0) { - this.remotePageState.setActiveSession({ - sessionId: this.activeSession.sessionId, - title: snapshot.title, - workspacePath: this.activeSession.workspacePath, - agentType: this.activeSession.agentType - }); - } - if (snapshot.modelCatalog) { - this.remoteModelController.applyCatalog(snapshot.modelCatalog); - } - this.setRemoteStatusText(this.hasRunningActiveTurn() - ? RemoteI18n.t('status.desktopProcessing') - : RemoteI18n.t('status.messagesSynced')); - if (snapshot.shouldSyncAfterTurnEnded) { - this.syncAfterTurnEnded(); - } - } - - hasRunningActiveTurn(): boolean { - return this.activeTurnMessage.id.length > 0 && - (this.activeTurnMessage.status || '').toLowerCase() === 'active'; - } - - currentActiveTurnId(): string { - const activeTurnMessage = this.isGeneralChatVisible() ? - this.generalChatPageState.activeTurnMessage : this.activeTurnMessage; - if (activeTurnMessage.turnId && activeTurnMessage.turnId.length > 0) { - return activeTurnMessage.turnId; - } - const activePrefix = 'active-'; - if (activeTurnMessage.id.indexOf(activePrefix) === 0) { - return activeTurnMessage.id.slice(activePrefix.length); - } - return ''; - } - - projectedTimelineItems(): ChatTimelineItem[] { - return this.chatTimelineStore.viewState(this.hasMoreMessages); - } - - startHeartbeat(): void { - this.remoteActivityViewModel.startHeartbeat(); - } - - stopHeartbeat(): void { - this.remoteActivityViewModel.stopHeartbeat(); - } - - async checkConnectionHealth(): Promise { - await this.remoteActivityViewModel.checkConnectionHealth(); - } - - resumeRemoteActivity(): void { - this.remoteActivityViewModel.resume(); - } - - hasRemoteBindingForResume(): boolean { - if (this.remotePageState.controlTargetType === 'account_device') { - return this.remotePageState.accountUserId.trim().length > 0 && - this.remotePageState.controlTargetDeviceId.trim().length > 0 && - this.connectionState !== ConnectionState.Idle && - this.connectionState !== ConnectionState.Disconnected; - } - return this.remoteUrl.trim().length > 0 && - this.userId.trim().length > 0 && - this.connectionState !== ConnectionState.Idle && - this.connectionState !== ConnectionState.Parsing && - this.connectionState !== ConnectionState.Pairing && - this.connectionState !== ConnectionState.Disconnected; - } - - private async reconnectActiveRemote(): Promise { - if (this.remotePageState.controlTargetType !== 'account_device') { - await this.connect(true); - return; - } - const targetId = this.remotePageState.controlTargetDeviceId; - const device = (await this.listCloudAccountDevices()).find((item: CloudAccountDevice): boolean => item.deviceId === targetId); - if (!device) { - throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); - } - await this.selectCloudAccountDevice(device); - } - - hasRemoteBindingForCodeHome(): boolean { - return this.remoteUrl.trim().length > 0 && - this.userId.trim().length > 0 && - (this.connectionState === ConnectionState.Connected || - this.connectionState === ConnectionState.Reconnecting || - this.connectionState === ConnectionState.Pairing || - this.connectionState === ConnectionState.Parsing); - } - - shortSessionId(sessionId: string): string { - if (sessionId.length <= 8) { - return sessionId; - } - return sessionId.slice(0, 4) + '...' + sessionId.slice(sessionId.length - 4); - } - - async syncAfterTurnEnded(): Promise { - if (this.isSyncingAfterTurn) { - return; - } - this.isSyncingAfterTurn = true; - try { - await this.loadActiveMessages(); - } finally { - this.isSyncingAfterTurn = false; - } - } - -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets index b9009f449..e34e22c5d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets @@ -12,12 +12,22 @@ export class AppShellState { @Trace showSettings: boolean = false; @Trace settingsMode: string = 'general'; @Trace showConnectSheet: boolean = false; + /** + * Mirror of the resolved master-detail layout mode. Only the presentation + * layer measures the viewport, so runtime logic that must branch on compact + * versus wide reads it from here. + */ + @Trace wideLayout: boolean = false; private accountReturnMode: string = ''; setSidebarVisible(visible: boolean): void { this.showSidebar = visible; } + setWideLayout(wide: boolean): void { + this.wideLayout = wide; + } + setSettingsVisible(visible: boolean): void { this.showSettings = visible; if (!visible) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets new file mode 100644 index 000000000..f86b14dd1 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets @@ -0,0 +1,191 @@ +import { + ChatMessage, + RemoteModelCatalog, + RemoteModelConfig, + RemoteSession, + SelectedImageAttachment, + SessionSummary +} from '../../model/RemoteModels'; +import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; +import { RemoteUiState } from '../../services/RemoteUiState'; + +/** Shared observable state for General Chat and Remote Chat conversations. */ +@ObservedV2 +export class ConversationCoreState { + @Trace sessions: RemoteSession[] = []; + @Trace activeSession: SessionSummary; + @Trace persistedMessages: ChatMessage[] = []; + @Trace optimisticMessages: ChatMessage[] = []; + @Trace activeTurnMessage: ChatMessage = RemoteUiState.emptyActiveTurn(); + @Trace hasMoreMessages: boolean = false; + @Trace timelineItems: ChatTimelineItem[] = []; + @Trace timelineRevision: number = 0; + @Trace isBusy: boolean = false; + @Trace modelCatalog: RemoteModelCatalog = RemoteUiState.emptyModelCatalog(); + @Trace selectedModelId: string = ''; + @Trace statusText: string = ''; + @Trace chatInput: string = ''; + @Trace selectedImages: SelectedImageAttachment[] = []; + @Trace isVoiceListening: boolean = false; + private readonly defaultAgentType: string; + private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); + + constructor(defaultAgentType: string) { + this.defaultAgentType = defaultAgentType; + this.activeSession = ConversationCoreState.emptySession(defaultAgentType); + } + + setSessions(sessions: RemoteSession[]): void { + this.sessions = sessions.slice(); + } + + setActiveSession(session: SessionSummary): void { + this.activeSession = { + sessionId: session.sessionId, + title: session.title, + workspacePath: session.workspacePath, + agentType: this.defaultAgentType === 'chat' ? 'chat' : session.agentType, + initialTurnId: session.initialTurnId + }; + } + + clearActiveSession(): void { + this.activeSession = ConversationCoreState.emptySession(this.defaultAgentType); + this.clearTimeline(); + } + + setTimelineProjection( + persistedMessages: ChatMessage[], + optimisticMessages: ChatMessage[], + activeTurnMessage: ChatMessage, + hasMoreMessages: boolean, + timelineItems: ChatTimelineItem[] + ): void { + this.timelineRevision = this.timelineRevisionTracker.update(timelineItems); + this.persistedMessages = persistedMessages.slice(); + this.optimisticMessages = optimisticMessages.slice(); + this.activeTurnMessage = activeTurnMessage.id.length > 0 ? + ConversationCoreState.copyMessage(activeTurnMessage) : + RemoteUiState.emptyActiveTurn(); + this.hasMoreMessages = hasMoreMessages; + this.timelineItems = timelineItems.slice(); + } + + setHasMoreMessages(hasMoreMessages: boolean): void { + this.hasMoreMessages = hasMoreMessages; + } + + clearTimeline(): void { + this.persistedMessages = []; + this.optimisticMessages = []; + this.activeTurnMessage = RemoteUiState.emptyActiveTurn(); + this.hasMoreMessages = false; + this.timelineItems = []; + this.timelineRevision = this.timelineRevisionTracker.reset(); + } + + setBusy(isBusy: boolean): void { + this.isBusy = isBusy; + } + + setModelCatalog(modelCatalog: RemoteModelCatalog, selectedModelId: string): void { + this.modelCatalog = ConversationCoreState.copyModelCatalog(modelCatalog); + this.selectedModelId = selectedModelId; + } + + setStatusText(statusText: string): void { + this.statusText = statusText; + } + + setChatInput(chatInput: string): void { + this.chatInput = chatInput; + } + + setSelectedImages(selectedImages: SelectedImageAttachment[]): void { + this.selectedImages = selectedImages.slice(); + } + + addSelectedImages(selectedImages: SelectedImageAttachment[]): void { + this.selectedImages = this.selectedImages.concat(selectedImages); + } + + removeSelectedImage(imageId: string): void { + this.selectedImages = this.selectedImages.filter((image: SelectedImageAttachment) => image.id !== imageId); + } + + clearComposer(): void { + this.chatInput = ''; + this.selectedImages = []; + } + + setVoiceListening(isVoiceListening: boolean): void { + this.isVoiceListening = isVoiceListening; + } + + hasRunningActiveTurn(): boolean { + return this.activeTurnMessage.id.length > 0 && + (this.activeTurnMessage.status || '').toLowerCase() === 'active'; + } + + latestUserMessageText(): string { + const candidates = this.persistedMessages.concat(this.optimisticMessages); + for (let index = candidates.length - 1; index >= 0; index--) { + if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { + return candidates[index].text; + } + } + return ''; + } + + private static emptySession(agentType: string): SessionSummary { + return { + sessionId: '', + title: '', + workspacePath: '', + agentType + }; + } + + private static copyMessage(message: ChatMessage): ChatMessage { + return { + id: message.id, + role: message.role, + text: message.text, + status: message.status, + renderVersion: message.renderVersion, + turnId: message.turnId, + detail: message.detail, + timestamp: message.timestamp, + thinking: message.thinking, + tools: message.tools ? message.tools.slice() : undefined, + items: message.items ? message.items.slice() : undefined, + images: message.images ? message.images.slice() : undefined + }; + } + + private static copyModelCatalog(modelCatalog: RemoteModelCatalog): RemoteModelCatalog { + return { + version: modelCatalog.version, + models: modelCatalog.models.map((model): RemoteModelConfig => { + return { + id: model.id, + name: model.name, + provider: model.provider, + base_url: model.base_url, + model_name: model.model_name, + context_window: model.context_window, + enabled: model.enabled, + capabilities: model.capabilities.slice(), + reasoning: model.reasoning + }; + }), + default_models: { + primary: modelCatalog.default_models.primary, + fast: modelCatalog.default_models.fast, + search: modelCatalog.default_models.search, + image_understanding: modelCatalog.default_models.image_understanding + }, + session_model_id: modelCatalog.session_model_id + }; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets index a7db3241b..a555f25aa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets @@ -18,6 +18,7 @@ import { toConversationUiSession } from '../components/ConversationUiModels'; import { AppRoute } from '../navigation/AppRouteContract'; +import { ConversationCoreState } from './ConversationCoreState'; import { GeneralChatPageState } from './GeneralChatPageState'; import { RemotePageState } from './RemotePageState'; @@ -32,6 +33,7 @@ export class ConversationViewState { connectionState: string = 'idle'; composerCapabilities: ChatComposerCapabilities = GENERAL_CHAT_COMPOSER_CAPABILITIES; isBusy: boolean = false; + isLoadingConversation: boolean = false; canStop: boolean = false; hasMoreMessages: boolean = false; timelineItems: ChatTimelineItem[] = []; @@ -63,49 +65,43 @@ export class ConversationViewState { } private static remote(remote: RemotePageState): ConversationViewState { - const state = new ConversationViewState(); - state.activeSession = toConversationUiSession(remote.activeSession); + const state = ConversationViewState.fromCore(remote.conversation); state.surface = ChatSurface.Remote; state.desktopName = remote.desktopName; state.workspaceBranch = remote.workspaceBranch; - state.statusText = remote.statusText; state.connectionState = remote.connectionState; + state.isLoadingConversation = remote.isLoadingConversation; state.composerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; - state.isBusy = remote.isBusy; - state.canStop = remote.hasRunningActiveTurn(); - state.hasMoreMessages = remote.hasMoreMessages; - state.timelineItems = remote.timelineItems; - state.timelineRevision = remote.timelineRevision; state.showSuggestionsWhenEmpty = false; - state.modelCatalog = toConversationUiModelCatalog(remote.modelCatalog); - state.selectedModelId = remote.selectedModelId; state.downloadingFilePath = remote.downloadingFilePath; state.downloadedFilePath = remote.downloadedFilePath; state.fileDownloadStatus = remote.fileDownloadStatus; - state.selectedImages = remote.selectedImages.map((image) => toConversationUiSelectedImage(image)); - state.isVoiceListening = remote.isVoiceListening; - state.chatInput = remote.chatInput; return state; } private static general(general: GeneralChatPageState, inlineStatus: string): ConversationViewState { - const state = new ConversationViewState(); - state.activeSession = toConversationUiSession(general.activeSession); - state.statusText = general.statusText; + const state = ConversationViewState.fromCore(general.conversation); state.inlineStatusText = inlineStatus; state.connectionState = GeneralChatServiceStatus.connectionState(general.serviceState); - state.isBusy = general.isBusy; - state.canStop = general.hasRunningActiveTurn(); - state.hasMoreMessages = general.hasMoreMessages; - state.timelineItems = general.timelineItems; - state.timelineRevision = general.timelineRevision; - state.modelCatalog = toConversationUiModelCatalog(general.modelCatalog); - state.selectedModelId = general.selectedModelId; - state.isSessionPinned = general.activeSession.sessionId.length > 0 && - general.pinnedSessionId() === general.activeSession.sessionId; - state.selectedImages = general.selectedImages.map((image) => toConversationUiSelectedImage(image)); - state.isVoiceListening = general.isVoiceListening; - state.chatInput = general.chatInput; + state.isSessionPinned = general.conversation.activeSession.sessionId.length > 0 && + general.pinnedSessionId() === general.conversation.activeSession.sessionId; + return state; + } + + private static fromCore(core: ConversationCoreState): ConversationViewState { + const state = new ConversationViewState(); + state.activeSession = toConversationUiSession(core.activeSession); + state.statusText = core.statusText; + state.isBusy = core.isBusy; + state.canStop = core.hasRunningActiveTurn(); + state.hasMoreMessages = core.hasMoreMessages; + state.timelineItems = core.timelineItems; + state.timelineRevision = core.timelineRevision; + state.modelCatalog = toConversationUiModelCatalog(core.modelCatalog); + state.selectedModelId = core.selectedModelId; + state.selectedImages = core.selectedImages.map((image) => toConversationUiSelectedImage(image)); + state.isVoiceListening = core.isVoiceListening; + state.chatInput = core.chatInput; return state; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets index 88351095f..7d51e1a0a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets @@ -1,4 +1,4 @@ -import { FilePreviewTarget } from './FilePreviewTarget'; +import { FilePreviewTarget } from '../../model/FilePreviewTarget'; export enum FilePreviewPhase { Idle = 'idle', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets index 5c4db437f..a6bcb8bd0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets @@ -5,50 +5,44 @@ import { SelectedImageAttachment, SessionSummary } from '../../model/RemoteModels'; -import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { GeneralChatServiceState } from '../../services/general-chat/GeneralChatServiceState'; -import { RemoteUiState } from '../../services/RemoteUiState'; +import { ConversationCoreState } from './ConversationCoreState'; @ObservedV2 export class GeneralChatPageState { - @Trace activeSession: SessionSummary = GeneralChatPageState.emptySession(); - @Trace sessions: RemoteSession[] = []; - @Trace persistedMessages: ChatMessage[] = []; - @Trace optimisticMessages: ChatMessage[] = []; - @Trace activeTurnMessage: ChatMessage = RemoteUiState.emptyActiveTurn(); - @Trace hasMoreMessages: boolean = false; - @Trace timelineItems: ChatTimelineItem[] = []; - @Trace timelineRevision: number = 0; - @Trace isBusy: boolean = false; + @Trace conversation: ConversationCoreState = new ConversationCoreState('chat'); @Trace serviceState: GeneralChatServiceState = GeneralChatServiceState.Unconfigured; @Trace apiUrl: string = ''; @Trace modelName: string = ''; @Trace hasApiKey: boolean = false; - @Trace modelCatalog: RemoteModelCatalog = RemoteUiState.emptyModelCatalog(); - @Trace selectedModelId: string = ''; - @Trace statusText: string = ''; - @Trace chatInput: string = ''; - @Trace selectedImages: SelectedImageAttachment[] = []; - @Trace isVoiceListening: boolean = false; - private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); + + get activeSession(): SessionSummary { return this.conversation.activeSession; } + get sessions(): RemoteSession[] { return this.conversation.sessions; } + get persistedMessages(): ChatMessage[] { return this.conversation.persistedMessages; } + get optimisticMessages(): ChatMessage[] { return this.conversation.optimisticMessages; } + get activeTurnMessage(): ChatMessage { return this.conversation.activeTurnMessage; } + get hasMoreMessages(): boolean { return this.conversation.hasMoreMessages; } + get timelineItems(): ChatTimelineItem[] { return this.conversation.timelineItems; } + get timelineRevision(): number { return this.conversation.timelineRevision; } + get isBusy(): boolean { return this.conversation.isBusy; } + get modelCatalog(): RemoteModelCatalog { return this.conversation.modelCatalog; } + get selectedModelId(): string { return this.conversation.selectedModelId; } + get statusText(): string { return this.conversation.statusText; } + get chatInput(): string { return this.conversation.chatInput; } + get selectedImages(): SelectedImageAttachment[] { return this.conversation.selectedImages; } + get isVoiceListening(): boolean { return this.conversation.isVoiceListening; } setActiveSession(session: SessionSummary): void { - this.activeSession = { - sessionId: session.sessionId, - title: session.title, - workspacePath: session.workspacePath, - agentType: 'chat', - initialTurnId: session.initialTurnId - }; + this.conversation.setActiveSession(session); } clearActiveSession(): void { - this.activeSession = GeneralChatPageState.emptySession(); - this.clearTimeline(); + this.conversation.clearActiveSession(); } setSessions(sessions: RemoteSession[]): void { - this.sessions = sessions.slice(); + this.conversation.setSessions(sessions); } setTimelineProjection( @@ -58,27 +52,21 @@ export class GeneralChatPageState { hasMoreMessages: boolean, timelineItems: ChatTimelineItem[] ): void { - this.timelineRevision = this.timelineRevisionTracker.update(timelineItems); - this.persistedMessages = persistedMessages.slice(); - this.optimisticMessages = optimisticMessages.slice(); - this.activeTurnMessage = activeTurnMessage.id.length > 0 ? - GeneralChatPageState.copyMessage(activeTurnMessage) : - RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = hasMoreMessages; - this.timelineItems = timelineItems.slice(); + this.conversation.setTimelineProjection( + persistedMessages, + optimisticMessages, + activeTurnMessage, + hasMoreMessages, + timelineItems + ); } clearTimeline(): void { - this.persistedMessages = []; - this.optimisticMessages = []; - this.activeTurnMessage = RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = false; - this.timelineItems = []; - this.timelineRevision = this.timelineRevisionTracker.reset(); + this.conversation.clearTimeline(); } setBusy(isBusy: boolean): void { - this.isBusy = isBusy; + this.conversation.setBusy(isBusy); } setConfiguration( @@ -98,46 +86,39 @@ export class GeneralChatPageState { } setModelCatalog(modelCatalog: RemoteModelCatalog, selectedModelId: string): void { - this.modelCatalog = { - version: modelCatalog.version, - models: modelCatalog.models.slice(), - default_models: modelCatalog.default_models, - session_model_id: modelCatalog.session_model_id - }; - this.selectedModelId = selectedModelId; + this.conversation.setModelCatalog(modelCatalog, selectedModelId); } setStatus(statusText: string): void { - this.statusText = statusText; + this.conversation.setStatusText(statusText); } setChatInput(chatInput: string): void { - this.chatInput = chatInput; + this.conversation.setChatInput(chatInput); } setSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = selectedImages.slice(); + this.conversation.setSelectedImages(selectedImages); } addSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = this.selectedImages.concat(selectedImages); + this.conversation.addSelectedImages(selectedImages); } removeSelectedImage(imageId: string): void { - this.selectedImages = this.selectedImages.filter((image: SelectedImageAttachment) => image.id !== imageId); + this.conversation.removeSelectedImage(imageId); } clearComposer(): void { - this.chatInput = ''; - this.selectedImages = []; + this.conversation.clearComposer(); } setVoiceListening(isVoiceListening: boolean): void { - this.isVoiceListening = isVoiceListening; + this.conversation.setVoiceListening(isVoiceListening); } recentSessions(): RemoteSession[] { - const recent = this.sessions.slice(); + const recent = this.conversation.sessions.slice(); recent.sort((first: RemoteSession, second: RemoteSession) => { if ((first.pinned === true) !== (second.pinned === true)) { return first.pinned === true ? -1 : 1; @@ -148,53 +129,20 @@ export class GeneralChatPageState { } pinnedSessionId(): string { - const pinned = this.sessions.find((session: RemoteSession) => session.pinned === true); + const pinned = this.conversation.sessions.find((session: RemoteSession) => session.pinned === true); return pinned ? pinned.id : ''; } hasRunningActiveTurn(): boolean { - return this.activeTurnMessage.id.length > 0 && - (this.activeTurnMessage.status || '').toLowerCase() === 'active'; + return this.conversation.hasRunningActiveTurn(); } latestUserMessageText(): string { - const candidates = this.persistedMessages.concat(this.optimisticMessages); - for (let index = candidates.length - 1; index >= 0; index--) { - if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { - return candidates[index].text; - } - } - return ''; + return this.conversation.latestUserMessageText(); } private sessionTimeValue(value: string): number { const parsed = new Date(value).getTime(); return Number.isNaN(parsed) ? 0 : parsed; } - - private static emptySession(): SessionSummary { - return { - sessionId: '', - title: '', - workspacePath: '', - agentType: 'chat' - }; - } - - private static copyMessage(message: ChatMessage): ChatMessage { - return { - id: message.id, - role: message.role, - text: message.text, - status: message.status, - renderVersion: message.renderVersion, - turnId: message.turnId, - detail: message.detail, - timestamp: message.timestamp, - thinking: message.thinking, - tools: message.tools ? message.tools.slice() : undefined, - items: message.items ? message.items.slice() : undefined, - images: message.images ? message.images.slice() : undefined - }; - } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets index 27e2d77f7..cb77d09d4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets @@ -94,8 +94,18 @@ export class RemoteCreateSessionState { this.errorText = ''; } + /** + * The desktop binds every Claw session to its assistant workspace and ignores + * the requested workspace_path, so a picked workspace only holds when it is + * paired with the code agent. No workspace means the chat option, which is + * what Claw is for. + */ submissionContext(): RemoteCreateSessionContext { - return new RemoteCreateSessionContext(this.selectedDeviceId, this.selectedWorkspacePath); + return new RemoteCreateSessionContext( + this.selectedDeviceId, + this.selectedWorkspacePath, + this.selectedWorkspacePath.length > 0 ? 'code' : 'Claw' + ); } clearWorkspace(): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets index 8af7b5da7..3284ff69f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets @@ -3,13 +3,13 @@ import { ChatMessage, RecentWorkspaceEntry, RemoteModelCatalog, - RemoteModelConfig, RemoteSession, SelectedImageAttachment, SessionSummary } from '../../model/RemoteModels'; -import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { RemoteUiState } from '../../services/RemoteUiState'; +import { ConversationCoreState } from './ConversationCoreState'; /** * Observable projection for the Remote home page. @@ -17,6 +17,7 @@ import { RemoteUiState } from '../../services/RemoteUiState'; */ @ObservedV2 export class RemotePageState { + @Trace conversation: ConversationCoreState = new ConversationCoreState('code'); @Trace desktopName: string = ''; @Trace desktopId: string = ''; @Trace remoteUrl: string = ''; @@ -28,10 +29,8 @@ export class RemotePageState { @Trace controlTargetType: string = 'none'; @Trace controlTargetDeviceId: string = ''; @Trace controlTargetDeviceName: string = ''; - @Trace statusText: string = ''; @Trace connectionState: string = 'idle'; @Trace connectionFailureKind: string = ''; - @Trace isBusy: boolean = false; @Trace isLoadingHome: boolean = false; @Trace showRemoteUrlInput: boolean = false; @Trace workspaceName: string = ''; @@ -43,28 +42,32 @@ export class RemotePageState { @Trace assistants: AssistantEntry[] = []; @Trace showWorkspacePicker: boolean = false; @Trace showAssistantPicker: boolean = false; - @Trace sessions: RemoteSession[] = []; - @Trace activeSession: SessionSummary = RemotePageState.emptySession(); - @Trace persistedMessages: ChatMessage[] = []; - @Trace optimisticMessages: ChatMessage[] = []; - @Trace activeTurnMessage: ChatMessage = RemoteUiState.emptyActiveTurn(); - @Trace hasMoreMessages: boolean = false; - @Trace timelineItems: ChatTimelineItem[] = []; - @Trace timelineRevision: number = 0; - @Trace modelCatalog: RemoteModelCatalog = RemoteUiState.emptyModelCatalog(); - @Trace selectedModelId: string = ''; @Trace downloadingFilePath: string = ''; @Trace downloadedFilePath: string = ''; @Trace fileDownloadStatus: string = ''; - @Trace chatInput: string = ''; - @Trace selectedImages: SelectedImageAttachment[] = []; - @Trace isVoiceListening: boolean = false; @Trace sessionQuery: string = ''; @Trace sessionFilter: string = 'all'; @Trace hasMoreSessions: boolean = false; @Trace isLoadingSessions: boolean = false; + @Trace isLoadingConversation: boolean = false; + @Trace pendingSessionId: string = ''; + @Trace isConversationDismissed: boolean = false; @Trace sessionErrorText: string = ''; - private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); + get activeSession(): SessionSummary { return this.conversation.activeSession; } + get sessions(): RemoteSession[] { return this.conversation.sessions; } + get persistedMessages(): ChatMessage[] { return this.conversation.persistedMessages; } + get optimisticMessages(): ChatMessage[] { return this.conversation.optimisticMessages; } + get activeTurnMessage(): ChatMessage { return this.conversation.activeTurnMessage; } + get hasMoreMessages(): boolean { return this.conversation.hasMoreMessages; } + get timelineItems(): ChatTimelineItem[] { return this.conversation.timelineItems; } + get timelineRevision(): number { return this.conversation.timelineRevision; } + get isBusy(): boolean { return this.conversation.isBusy; } + get modelCatalog(): RemoteModelCatalog { return this.conversation.modelCatalog; } + get selectedModelId(): string { return this.conversation.selectedModelId; } + get statusText(): string { return this.conversation.statusText; } + get chatInput(): string { return this.conversation.chatInput; } + get selectedImages(): SelectedImageAttachment[] { return this.conversation.selectedImages; } + get isVoiceListening(): boolean { return this.conversation.isVoiceListening; } setQuery(query: string): void { this.sessionQuery = query; @@ -125,7 +128,7 @@ export class RemotePageState { } setStatusText(statusText: string): void { - this.statusText = statusText; + this.conversation.setStatusText(statusText); } setConnectionState(connectionState: string): void { @@ -137,7 +140,7 @@ export class RemotePageState { } setBusy(isBusy: boolean): void { - this.isBusy = isBusy; + this.conversation.setBusy(isBusy); } setLoadingHome(isLoadingHome: boolean): void { @@ -184,24 +187,20 @@ export class RemotePageState { } setSessions(sessions: RemoteSession[], hasMore: boolean): void { - this.sessions = sessions.slice(); + this.conversation.setSessions(sessions); this.hasMoreSessions = hasMore; this.sessionErrorText = ''; } setActiveSession(session: SessionSummary): void { - this.activeSession = { - sessionId: session.sessionId, - title: session.title, - workspacePath: session.workspacePath, - agentType: session.agentType, - initialTurnId: session.initialTurnId - }; + this.conversation.setActiveSession(session); } clearActiveSession(): void { - this.activeSession = RemotePageState.emptySession(); - this.clearTimeline(); + this.conversation.clearActiveSession(); + this.isLoadingConversation = false; + this.pendingSessionId = ''; + this.isConversationDismissed = false; this.setModelCatalog(RemoteUiState.emptyModelCatalog(), ''); } @@ -212,32 +211,25 @@ export class RemotePageState { hasMoreMessages: boolean, timelineItems: ChatTimelineItem[] ): void { - this.timelineRevision = this.timelineRevisionTracker.update(timelineItems); - this.persistedMessages = persistedMessages.slice(); - this.optimisticMessages = optimisticMessages.slice(); - this.activeTurnMessage = activeTurnMessage.id.length > 0 ? - RemotePageState.copyMessage(activeTurnMessage) : - RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = hasMoreMessages; - this.timelineItems = timelineItems.slice(); + this.conversation.setTimelineProjection( + persistedMessages, + optimisticMessages, + activeTurnMessage, + hasMoreMessages, + timelineItems + ); } setHasMoreMessages(hasMoreMessages: boolean): void { - this.hasMoreMessages = hasMoreMessages; + this.conversation.setHasMoreMessages(hasMoreMessages); } clearTimeline(): void { - this.persistedMessages = []; - this.optimisticMessages = []; - this.activeTurnMessage = RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = false; - this.timelineItems = []; - this.timelineRevision = this.timelineRevisionTracker.reset(); + this.conversation.clearTimeline(); } setModelCatalog(modelCatalog: RemoteModelCatalog, selectedModelId: string): void { - this.modelCatalog = RemotePageState.copyModelCatalog(modelCatalog); - this.selectedModelId = selectedModelId; + this.conversation.setModelCatalog(modelCatalog, selectedModelId); } setDownloadStatus(downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string): void { @@ -257,43 +249,57 @@ export class RemotePageState { } setChatInput(chatInput: string): void { - this.chatInput = chatInput; + this.conversation.setChatInput(chatInput); } setSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = selectedImages.slice(); + this.conversation.setSelectedImages(selectedImages); } addSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = this.selectedImages.concat(selectedImages); + this.conversation.addSelectedImages(selectedImages); } removeSelectedImage(imageId: string): void { - this.selectedImages = this.selectedImages.filter((image: SelectedImageAttachment) => image.id !== imageId); + this.conversation.removeSelectedImage(imageId); } clearComposer(): void { - this.chatInput = ''; - this.selectedImages = []; + this.conversation.clearComposer(); } setVoiceListening(isVoiceListening: boolean): void { - this.isVoiceListening = isVoiceListening; + this.conversation.setVoiceListening(isVoiceListening); } setLoading(loading: boolean): void { this.isLoadingSessions = loading; } + setConversationLoading(loading: boolean): void { + this.isLoadingConversation = loading; + } + + setPendingSessionId(sessionId: string): void { + this.pendingSessionId = sessionId; + } + + setConversationDismissed(dismissed: boolean): void { + this.isConversationDismissed = dismissed; + } + setError(errorText: string): void { this.sessionErrorText = errorText; this.isLoadingSessions = false; } clear(): void { - this.sessions = []; + this.conversation.setSessions([]); this.hasMoreSessions = false; this.isLoadingSessions = false; + this.isLoadingConversation = false; + this.pendingSessionId = ''; + this.isConversationDismissed = false; this.isLoadingHome = false; this.sessionErrorText = ''; } @@ -306,7 +312,7 @@ export class RemotePageState { visibleSessions(): RemoteSession[] { const query = this.sessionQuery.trim().toLowerCase(); - return this.sessions.filter((item: RemoteSession) => { + return this.conversation.sessions.filter((item: RemoteSession) => { if (item.id.length === 0 || item.status === 'archived') { return false; } @@ -315,59 +321,6 @@ export class RemotePageState { } hasRunningActiveTurn(): boolean { - return this.activeTurnMessage.id.length > 0 && - (this.activeTurnMessage.status || '').toLowerCase() === 'active'; - } - - private static emptySession(): SessionSummary { - return { - sessionId: '', - title: '', - workspacePath: '', - agentType: 'code' - }; - } - - private static copyMessage(message: ChatMessage): ChatMessage { - return { - id: message.id, - role: message.role, - text: message.text, - status: message.status, - renderVersion: message.renderVersion, - turnId: message.turnId, - detail: message.detail, - timestamp: message.timestamp, - thinking: message.thinking, - tools: message.tools ? message.tools.slice() : undefined, - items: message.items ? message.items.slice() : undefined, - images: message.images ? message.images.slice() : undefined - }; - } - - private static copyModelCatalog(modelCatalog: RemoteModelCatalog): RemoteModelCatalog { - return { - version: modelCatalog.version, - models: modelCatalog.models.map((model): RemoteModelConfig => { - return { - id: model.id, - name: model.name, - provider: model.provider, - base_url: model.base_url, - model_name: model.model_name, - context_window: model.context_window, - enabled: model.enabled, - capabilities: model.capabilities.slice(), - reasoning: model.reasoning - }; - }), - default_models: { - primary: modelCatalog.default_models.primary, - fast: modelCatalog.default_models.fast, - search: modelCatalog.default_models.search, - image_understanding: modelCatalog.default_models.image_understanding - }, - session_model_id: modelCatalog.session_model_id - }; + return this.conversation.hasRunningActiveTurn(); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets similarity index 98% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets index 53bf57347..2b723f14d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets @@ -4,7 +4,7 @@ import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; -import { AppShellState } from './AppShellState'; +import { AppShellState } from '../state/AppShellState'; /** Owns application navigation and global overlay state. */ export class AppShellViewModel { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets new file mode 100644 index 000000000..b43aa9661 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets @@ -0,0 +1,1038 @@ +import { + RecentWorkspaceEntry, + RemoteImageContext, + RemoteQuestionAnswerPayload, + RemoteSession, + SessionSummary, + SelectedImageAttachment +} from '../../model/RemoteModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; +import { ChatTimelineState } from '../../services/ChatTimelineStore'; +import { ClipboardService } from '../../services/ClipboardService'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { ImagePickerService } from '../../services/ImagePickerService'; +import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; +import { GeneralChatConversationViewModel } from './GeneralChatConversationViewModel'; +import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; +import { + GeneralChatServiceState, + GeneralChatServiceStatus +} from '../../services/general-chat/GeneralChatServiceState'; +import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; +import { + RemoteChatPollingCursor, + RemoteChatPollingLifecycleController, + RemoteChatPollingSnapshot +} from '../../services/RemoteChatPollingLifecycleController'; +import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteModelController } from '../../services/RemoteModelController'; +import { RemoteToolActionController } from '../../services/RemoteToolActionController'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; +import { VoiceInputRouteSnapshot } from '../../services/VoiceInputLifecycleController'; +import { AppRootRouteState } from '../navigation/AppRootRouteState'; +import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { RemotePageState } from '../state/RemotePageState'; +import { ConversationViewModel } from './ConversationViewModel'; +import { AppShellViewModel } from './AppShellViewModel'; +import { FilePreviewController } from './FilePreviewController'; +import { RemoteConnectionController } from './RemoteConnectionController'; +import { RemoteSessionViewModel } from './RemoteSessionViewModel'; +import { SettingsController } from './SettingsController'; +const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; + +export interface ConversationControllerHooks { + readonly currentRoute: () => AppRoute; +} + +export interface RemoteConversationHooks { + readonly isConversationContext: (sessionId: string) => boolean; + readonly isFilePreviewVisible: () => boolean; + readonly stopVoiceInput: () => Promise; + readonly showToast: (message: string) => boolean; + readonly selectAssistantWorkspace: (path: string) => Promise; +} + +export interface RemoteConversationDependencies { + readonly timeline: ConversationViewModel; + readonly chat: RemoteChatCommandController; + readonly polling: RemoteChatPollingLifecycleController; + readonly models: RemoteModelController; + readonly files: RemoteFileDownloadController; + readonly tools: RemoteToolActionController; + readonly connection: RemoteConnectionController; + readonly imagePicker: ImagePickerService; + readonly clipboard: ClipboardService; + readonly sessions: RemoteSessionViewModel; + readonly sessionManager: RemoteSessionManager; + readonly workspace: RemoteWorkspaceCoordinator; + readonly settings: SettingsController; + readonly appShell: AppShellViewModel; + readonly filePreview: FilePreviewController; + readonly generalCommands: GeneralChatCommandController; + readonly generalConversation: GeneralChatConversationViewModel; + readonly generalDrafts: GeneralChatDraftLifecycleController; + readonly hooks: RemoteConversationHooks; +} + +/** Owns route-dependent composer and voice presentation state. */ +export class ConversationController { + private readonly general: GeneralChatPageState; + private readonly remote: RemotePageState; + private readonly remoteCreate: RemoteCreateSessionState; + private readonly hooks: ConversationControllerHooks; + private readonly remoteRuntime?: RemoteConversationDependencies; + private knownPollVersionValue: number = 0; + private knownModelCatalogVersion: number = 0; + private knownRemoteMessageCount: number = 0; + private isSyncingAfterTurn: boolean = false; + private remoteCreateWorkspaceLoadVersion: number = 0; + + constructor( + general: GeneralChatPageState, + remote: RemotePageState, + remoteCreate: RemoteCreateSessionState, + hooks: ConversationControllerHooks, + remoteRuntime?: RemoteConversationDependencies + ) { + this.general = general; + this.remote = remote; + this.remoteCreate = remoteCreate; + this.hooks = hooks; + this.remoteRuntime = remoteRuntime; + } + + visibleChatInput(): string { + const route = this.hooks.currentRoute(); + return route === AppRoute.RemoteCreate ? this.remoteCreate.draft : + AppRootRouteState.chatInput(route, this.general, this.remote); + } + + visibleSelectedImages(): SelectedImageAttachment[] { + return AppRootRouteState.selectedImages(this.hooks.currentRoute(), this.general, this.remote); + } + + visibleVoiceListening(): boolean { + const route = this.hooks.currentRoute(); + return route === AppRoute.RemoteCreate ? this.remoteCreate.isVoiceListening : + AppRootRouteState.voiceListening(route, this.general, this.remote); + } + + setChatInput(route: AppRoute, value: string): void { + if (route === AppRoute.RemoteCreate) { + this.remoteCreate.setDraft(value); + return; + } + AppRootRouteState.setChatInput(route, value, this.general, this.remote); + } + + addSelectedImages(route: AppRoute, images: SelectedImageAttachment[]): void { + AppRootRouteState.addSelectedImages(route, images, this.general, this.remote); + } + + removeSelectedImage(route: AppRoute, imageId: string): void { + AppRootRouteState.removeSelectedImage(route, imageId, this.general, this.remote); + } + + setVoiceListening(route: AppRoute, isVoiceListening: boolean): void { + if (route === AppRoute.RemoteCreate) { + this.remoteCreate.isVoiceListening = isVoiceListening; + return; + } + AppRootRouteState.setVoiceListening(route, isVoiceListening, this.general, this.remote); + } + + clearAllVoiceListening(): void { + this.general.setVoiceListening(false); + this.remote.setVoiceListening(false); + this.remoteCreate.isVoiceListening = false; + } + + visibleBusy(): boolean { + return this.isGeneralComposerRoute(this.hooks.currentRoute()) ? + this.general.isBusy : this.remote.isBusy; + } + + visibleStatusText(): string { + return this.isGeneralComposerRoute(this.hooks.currentRoute()) ? + this.general.statusText : this.remote.statusText; + } + + setVisibleStatusText(statusText: string): void { + if (this.isGeneralComposerRoute(this.hooks.currentRoute())) { + this.general.setStatus(statusText); + return; + } + this.remote.setStatusText(statusText); + } + + voiceInputSnapshot(route: AppRoute): VoiceInputRouteSnapshot { + if (route === AppRoute.RemoteCreate) { + return { + routeId: `${route}`, + isListening: this.remoteCreate.isVoiceListening, + isBusy: this.remoteCreate.isSubmitting, + inputText: this.remoteCreate.draft, + selectedImageCount: 0 + }; + } + return AppRootRouteState.snapshot(route, this.visibleBusy(), this.general, this.remote); + } + + isGeneralComposerRoute(route: AppRoute): boolean { + return AppRouteContract.isGeneralComposerRoute(route); + } + + knownPollVersion(): number { + return this.knownPollVersionValue; + } + + resetKnownRemoteState(): void { + this.knownPollVersionValue = 0; + this.knownModelCatalogVersion = 0; + this.knownRemoteMessageCount = 0; + } + + updateKnownMessageCount(pollVersion: number, knownMessageCount: number): void { + this.knownRemoteMessageCount = knownMessageCount; + this.updateChatPollingCursor(pollVersion, knownMessageCount); + } + + updateKnownModelCatalogVersion(version: number): void { + this.knownModelCatalogVersion = version; + this.requireRemoteRuntime().polling.updateKnownModelCatalogVersion(version); + } + + async loadRemoteMessages(): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.chat.loadMessages( + this.remote.activeSession.sessionId || '', + runtime.hooks.isConversationContext + ); + } + + async loadRemoteModelCatalog(sessionId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.models.loadCatalog( + sessionId, + runtime.connection.ensureAvailable(), + runtime.hooks.isConversationContext + ); + } + + async selectRemoteModel(modelId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.models.selectModel( + modelId, + this.remote.activeSession.sessionId || '', + this.remote.isBusy, + runtime.connection.ensureAvailable() + ); + } + + async loadOlderRemoteMessages(): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.chat.loadOlderMessages( + this.remote.activeSession.sessionId || '', + this.knownPollVersionValue, + this.remote.hasMoreMessages, + this.remote.isBusy + ); + } + + async sendRemoteMessage(): Promise { + const runtime = this.requireRemoteRuntime(); + if (this.remote.isVoiceListening) { + await runtime.hooks.stopVoiceInput(); + } + const rawText = this.remote.chatInput.trim(); + const images = this.remote.selectedImages.slice(); + const text = rawText.length > 0 ? rawText : + (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); + const sessionId = this.remote.activeSession.sessionId || ''; + if ((!text && images.length === 0) || !sessionId || this.remote.isBusy || + !runtime.connection.ensureAvailable()) { + return; + } + this.remote.clearComposer(); + const localMessage = RemoteUiState.localUserMessage(text, images); + runtime.timeline.appendOptimisticMessage(localMessage); + const pendingActiveId = runtime.timeline.setPendingActiveTurn(localMessage.id); + this.syncRemoteTimeline(); + RemoteLogger.info(`chat send queued session=${this.shortSessionId(sessionId)} pending=${pendingActiveId}`); + this.startRemotePolling(); + runtime.polling.nudge(); + const imageContexts: RemoteImageContext[] = images.length > 0 ? + runtime.imagePicker.toRemoteContexts(images) : []; + await runtime.chat.sendPreparedMessage( + sessionId, + text, + this.remote.activeSession.agentType, + rawText, + images, + imageContexts, + localMessage.id, + pendingActiveId, + this.remote.isBusy, + true + ); + } + + async stopRemoteTask(): Promise { + const runtime = this.requireRemoteRuntime(); + const sessionId = this.remote.activeSession.sessionId || ''; + if (!sessionId) { + return; + } + await runtime.chat.stopTask( + sessionId, + this.remote.activeTurnMessage.id, + this.remoteActiveTurnId(), + runtime.connection.ensureAvailable() + ); + } + + async renameRemoteSession(title: string): Promise { + const runtime = this.requireRemoteRuntime(); + const nextTitle = title.trim(); + if (!this.remote.activeSession.sessionId || nextTitle.length === 0 || + nextTitle === this.remote.activeSession.title || this.remote.isBusy) { + return; + } + await runtime.chat.renameActiveSession( + this.remote.activeSession, + nextTitle, + this.remote.isBusy, + runtime.connection.ensureAvailable() + ); + } + + async copyRemoteMessage(text: string): Promise { + if (text.trim().length === 0) { + return; + } + try { + await this.requireRemoteRuntime().clipboard.writeText(text); + this.remote.setStatusText(RemoteI18n.t('status.messageCopied')); + } catch (err) { + this.remote.setStatusText(ConnectionErrorPolicy.errorText(err)); + } + } + + async downloadRemoteFile(path: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.files.download( + path, + this.remote.activeSession.sessionId || '', + this.remote.isBusy, + runtime.connection.ensureAvailable() + ); + } + + retryRemoteMessage(text: string): void { + if (this.remote.isBusy || !this.requireRemoteRuntime().connection.ensureAvailable()) { + return; + } + this.remote.setChatInput(text); + this.sendRemoteMessage(); + } + + async approveRemoteTool(toolId: string, updatedInput?: Object): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.tools.approve( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable(), updatedInput + ); + } + + async rejectRemoteTool(toolId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.tools.reject( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable() + ); + } + + async cancelRemoteTool(toolId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.tools.cancel( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable() + ); + } + + async answerRemoteQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.tools.answer( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable(), answers + ); + } + + resetRemoteTimeline(sessionId: string): void { + const runtime = this.requireRemoteRuntime(); + runtime.timeline.reset(sessionId); + this.knownPollVersionValue = 0; + this.syncRemoteTimeline(); + } + + syncRemoteTimeline(): void { + const runtime = this.requireRemoteRuntime(); + const state: ChatTimelineState = runtime.timeline.snapshotState(); + this.remote.setTimelineProjection( + state.persistedMessages, + state.optimisticMessages, + state.activeTurn || RemoteUiState.emptyActiveTurn(), + this.remote.hasMoreMessages, + runtime.timeline.viewState(this.remote.hasMoreMessages) + ); + this.remote.setModelCatalog(state.modelCatalog, state.selectedModelId); + } + + startRemotePolling(): void { + this.requireRemoteRuntime().polling.startActiveSession({ + sessionId: this.remote.activeSession.sessionId || '', + cursor: this.currentChatPollingCursor(), + activeTurn: this.remote.activeTurnMessage + }); + } + + applyRemoteSnapshot(snapshot: RemoteChatPollingSnapshot): void { + const runtime = this.requireRemoteRuntime(); + if (!runtime.hooks.isConversationContext(snapshot.sessionId)) { + return; + } + runtime.timeline.applySnapshot(snapshot); + this.syncRemoteTimeline(); + this.knownPollVersionValue = snapshot.cursor.pollVersion; + this.knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion; + this.knownRemoteMessageCount = snapshot.cursor.knownMessageCount; + if (snapshot.title.length > 0) { + this.remote.setActiveSession({ + sessionId: this.remote.activeSession.sessionId, + title: snapshot.title, + workspacePath: this.remote.activeSession.workspacePath, + agentType: this.remote.activeSession.agentType + }); + } + if (snapshot.modelCatalog) { + runtime.models.applyCatalog(snapshot.modelCatalog); + } + this.remote.setStatusText(this.hasRunningRemoteTurn() + ? RemoteI18n.t('status.desktopProcessing') + : RemoteI18n.t('status.messagesSynced')); + if (snapshot.shouldSyncAfterTurnEnded) { + this.syncAfterRemoteTurnEnded(); + } + } + + hasRunningRemoteTurn(): boolean { + return this.remote.activeTurnMessage.id.length > 0 && + (this.remote.activeTurnMessage.status || '').toLowerCase() === 'active'; + } + + remoteActiveTurnId(): string { + const active = this.remote.activeTurnMessage; + if (active.turnId && active.turnId.length > 0) { + return active.turnId; + } + return active.id.indexOf('active-') === 0 ? active.id.slice('active-'.length) : ''; + } + + projectedRemoteTimelineItems(): ChatTimelineItem[] { + return this.requireRemoteRuntime().timeline.viewState(this.remote.hasMoreMessages); + } + + async createRemoteSession(agentType: string, inPlace: boolean = false): Promise { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + await runtime.sessions.createSession( + agentType, + '', + inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : + (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) + ); + } + + openRemoteCreateSession(): void { + const runtime = this.requireRemoteRuntime(); + if (!runtime.connection.ensureAvailable()) { + return; + } + const deviceId = this.remote.controlTargetDeviceId || this.remote.desktopId; + const deviceName = this.remote.controlTargetDeviceName || this.remote.desktopName; + this.remoteCreate.prepare(deviceId, deviceName, this.remote.selectedModelId); + if (deviceId.length > 0) { + this.remoteCreate.setDevices([{ + deviceId, + deviceName: deviceName || deviceId, + online: true + }]); + } + this.remoteCreate.setWorkspaces(this.remote.recentWorkspaces); + runtime.appShell.pushRoute(AppRoute.RemoteCreate); + this.loadRemoteCreateChoices(); + this.loadRemoteCreateModelCatalog(); + } + + closeRemoteCreateSession(): void { + const runtime = this.requireRemoteRuntime(); + this.remoteCreateWorkspaceLoadVersion += 1; + runtime.hooks.stopVoiceInput(); + this.remoteCreate.closeMenu(); + runtime.appShell.popRoute(AppRoute.RemoteHome); + } + + async loadRemoteCreateChoices(): Promise { + await Promise.all([ + this.loadRemoteCreateDevices(), + this.loadRemoteCreateWorkspaces() + ]); + } + + async loadRemoteCreateModelCatalog(): Promise { + const runtime = this.requireRemoteRuntime(); + if (this.remote.modelCatalog.models.length > 0) { + return; + } + try { + const catalog = await runtime.sessionManager.getModelCatalog(); + const selectedModelId = RemoteUiState.selectedModelIdForCatalog(catalog, this.remote.selectedModelId); + this.remote.setModelCatalog(catalog, selectedModelId); + this.remoteCreate.setSelectedModelId(selectedModelId); + } catch (_err) { + // Model selection remains hidden when the remote does not expose a catalog. + } + } + + async loadRemoteCreateDevices(): Promise { + const runtime = this.requireRemoteRuntime(); + this.remoteCreate.isLoadingDevices = this.remoteCreate.devices.length === 0; + try { + const phoneDeviceId = runtime.connection.getDeviceId(); + const accountDevices = await runtime.settings.listCloudAccountDevices(); + const devices = accountDevices.filter((device: CloudAccountDevice): boolean => + device.online && device.deviceId !== phoneDeviceId + ); + const currentId = this.remoteCreate.selectedDeviceId; + if (currentId.length > 0 && + !devices.some((device: CloudAccountDevice): boolean => device.deviceId === currentId)) { + devices.unshift({ + deviceId: currentId, + deviceName: this.remoteCreate.selectedDeviceName || currentId, + online: true + }); + } + this.remoteCreate.setDevices(devices); + } catch (_err) { + const currentId = this.remoteCreate.selectedDeviceId; + if (currentId.length > 0) { + this.remoteCreate.setDevices([{ + deviceId: currentId, + deviceName: this.remoteCreate.selectedDeviceName || currentId, + online: true + }]); + } else { + this.remoteCreate.setDevices([]); + } + this.remoteCreate.errorText = RemoteI18n.t('remote.create.deviceLoadFailed'); + } + } + + async loadRemoteCreateWorkspaces(): Promise { + const runtime = this.requireRemoteRuntime(); + const loadVersion = ++this.remoteCreateWorkspaceLoadVersion; + const deviceId = this.remoteCreate.selectedDeviceId; + this.remoteCreate.isLoadingWorkspaces = this.remoteCreate.workspaces.length === 0; + try { + const workspaces = await runtime.workspace.recentWorkspaces(); + if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || + deviceId !== this.remoteCreate.selectedDeviceId) { + return; + } + this.remoteCreate.setWorkspaces(workspaces); + } catch (_err) { + if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || + deviceId !== this.remoteCreate.selectedDeviceId) { + return; + } + this.remoteCreate.setWorkspaces([]); + this.remoteCreate.errorText = RemoteI18n.t('remote.create.workspaceLoadFailed'); + } + } + + toggleRemoteCreateDevices(): void { + this.remoteCreate.toggleMenu('devices'); + if (this.remoteCreate.openMenu === 'devices' && this.remoteCreate.devices.length === 0) { + this.loadRemoteCreateDevices(); + } + } + + toggleRemoteCreateWorkspaces(): void { + this.remoteCreate.toggleMenu('workspaces'); + if (this.remoteCreate.openMenu === 'workspaces' && this.remoteCreate.workspaces.length === 0) { + this.loadRemoteCreateWorkspaces(); + } + } + + async selectRemoteCreateDevice(device: CloudAccountDevice): Promise { + const runtime = this.requireRemoteRuntime(); + if (device.deviceId === this.remoteCreate.selectedDeviceId) { + this.remoteCreate.closeMenu(); + return; + } + const draft = this.remoteCreate.draft; + this.remoteCreate.closeMenu(); + this.remoteCreate.isLoadingWorkspaces = true; + try { + await runtime.settings.selectCloudAccountDevice(device, false); + this.remoteCreate.selectDevice(device); + this.remoteCreate.setDraft(draft); + await this.loadRemoteCreateWorkspaces(); + } catch (err) { + this.remoteCreate.isLoadingWorkspaces = false; + this.remoteCreate.errorText = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceSwitchFailed'); + } + } + + selectRemoteCreateWorkspace(path: string): void { + const workspace = this.remoteCreate.workspaces + .find((item: RecentWorkspaceEntry): boolean => item.path === path); + this.remoteCreate.selectWorkspace(workspace); + } + + async submitRemoteCreateSession(): Promise { + const runtime = this.requireRemoteRuntime(); + const instruction = this.remoteCreate.draft.trim(); + if (instruction.length === 0 || this.remoteCreate.isSubmitting || !runtime.connection.ensureAvailable()) { + return; + } + const context = this.remoteCreate.submissionContext(); + const activeDeviceId = this.remote.controlTargetDeviceId || this.remote.desktopId; + if (context.deviceId.length === 0 || context.deviceId !== activeDeviceId) { + this.remoteCreate.errorText = RemoteI18n.t('remote.create.deviceMismatch'); + return; + } + this.remoteCreate.isSubmitting = true; + this.remoteCreate.errorText = ''; + this.remoteCreate.closeMenu(); + try { + if (context.workspacePath.length > 0) { + await runtime.sessions.createSessionInWorkspace( + context.workspacePath, + this.remote.workspacePath, + instruction, + context.agentType, + undefined, + this.remoteCreate.selectedModelId + ); + } else { + await this.bindAssistantWorkspace(); + await runtime.sessions.createSession( + context.agentType, + instruction, + undefined, + this.remoteCreate.selectedModelId + ); + } + if (runtime.appShell.isRoute(AppRoute.RemoteCreate)) { + this.remoteCreate.errorText = this.remote.statusText || RemoteI18n.t('remote.create.submitFailed'); + } + } catch (err) { + this.remoteCreate.errorText = err instanceof Error ? err.message : + RemoteI18n.t('remote.create.submitFailed'); + } finally { + this.remoteCreate.isSubmitting = false; + } + } + + /** + * The chat option creates a Claw session, and the desktop always binds those + * to its assistant workspace. Follow it there first, otherwise the app stays + * bound to the code workspace it was on and the new chat is listed, titled + * and file-scoped as if it had been created inside that workspace. + */ + private async bindAssistantWorkspace(): Promise { + const runtime = this.requireRemoteRuntime(); + if (this.remote.workspaceKind === 'assistant') { + return; + } + try { + const assistants = await runtime.workspace.assistants(); + if (assistants.length === 0) { + return; + } + await runtime.hooks.selectAssistantWorkspace(assistants[0].path); + } catch (err) { + RemoteLogger.warn(`assistant workspace bind failed: ${String(err)}`); + } + } + + async createRemoteSessionInWorkspace( + path: string, + agentType: string = 'code', + inPlace: boolean = false + ): Promise { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + await runtime.sessions.createSessionInWorkspace( + path, + this.remote.workspacePath, + '', + agentType, + inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : + (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) + ); + } + + async openRemoteSession(item: RemoteSession, inPlace: boolean = false): Promise { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + await runtime.sessions.openSession( + item, + this.remote.workspacePath, + inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : + (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) + ); + } + + applyRemoteActiveSession(session: SessionSummary): void { + const runtime = this.requireRemoteRuntime(); + const current = this.remote.activeSession; + if (runtime.hooks.isFilePreviewVisible() && + (current.sessionId !== session.sessionId || current.workspacePath !== session.workspacePath)) { + runtime.filePreview.close(); + } + this.remote.setActiveSession(session); + } + + async deleteRemoteSession(item: RemoteSession): Promise { + await this.requireRemoteRuntime().sessions.deleteSession(item, this.remote.workspacePath); + } + + openHomeSession(session: RemoteSession, inPlace: boolean = false): void { + this.requireRemoteRuntime().filePreview.close(); + if (session.agentType === 'chat') { + this.openGeneralSession(session); + return; + } + this.openRemoteSession(session, inPlace); + } + + async deleteHomeSession(session: RemoteSession): Promise { + if (session.agentType !== 'chat') { + await this.deleteRemoteSession(session); + return; + } + await this.requireRemoteRuntime().generalCommands.deleteSession(session, this.general.isBusy); + } + + activeGeneralChatAsRemoteSession(): RemoteSession { + const active = this.general.activeSession; + return { + id: active.sessionId, + title: active.title, + agentType: 'chat', + status: 'ready', + updatedAt: '', + createdAt: '', + messageCount: this.general.timelineItems.length, + workspacePath: active.workspacePath + }; + } + + activeGeneralUploadedFileCount(): number { + let count = 0; + this.general.timelineItems.forEach((item: ChatTimelineItem) => { + if (item.message && item.message.images) { + count += item.message.images.length; + } + }); + return count; + } + + async archiveHomeSession(session: RemoteSession, archived: boolean): Promise { + await this.requireRemoteRuntime().generalCommands.archiveSession(session, archived, this.general.isBusy); + } + + async exportHomeSession(session: RemoteSession): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.generalCommands.exportSession( + session, + this.general.isBusy, + async (text: string): Promise => runtime.clipboard.writeText(text) + ); + } + + async openGeneralSession(item: RemoteSession): Promise { + const runtime = this.requireRemoteRuntime(); + if (this.general.isBusy) { + return; + } + runtime.polling.stop(); + runtime.generalConversation.stop(false); + await runtime.generalCommands.openSession( + item, + this.general.isBusy, + async (sessionId: string): Promise => runtime.generalDrafts.restore(sessionId), + (_sessionId: string): void => runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome) + ); + } + + async startGeneralChat(text: string): Promise { + const runtime = this.requireRemoteRuntime(); + const trimmed = text.trim(); + if (trimmed.length === 0 || this.general.isBusy) { + return; + } + runtime.polling.stop(); + runtime.generalConversation.stop(false); + runtime.generalDrafts.cancel(); + const created = await runtime.generalCommands.createSession( + trimmed, + this.general.isBusy, + async (): Promise => runtime.generalDrafts.clearHomeNow(), + (_sessionId: string): void => runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome) + ); + if (created) { + await runtime.generalConversation.sendMessage(); + } + } + + async sendVisibleMessage(): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + if ((this.general.activeSession.sessionId || '').length === 0) { + this.startVisibleGeneralChat(); + return; + } + await runtime.generalConversation.sendMessage(); + return; + } + await this.sendRemoteMessage(); + } + + async stopVisibleTask(): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + runtime.generalConversation.stop(true); + return; + } + await this.stopRemoteTask(); + } + + closeActiveChat(): void { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + runtime.hooks.stopVoiceInput(); + if (runtime.appShell.isRoute(AppRoute.GeneralChat)) { + runtime.generalDrafts.persistVisible(this.general.chatInput); + runtime.generalConversation.stop(true); + runtime.appShell.popRoute(AppRoute.ChatHome); + this.restoreGeneralChatDraft(GENERAL_CHAT_HOME_DRAFT_ID); + return; + } + runtime.polling.stop(); + this.remote.setConversationDismissed(true); + runtime.appShell.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + } + + async renameVisibleSession(title: string): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + await runtime.generalCommands.renameActiveSession(this.general.activeSession, title); + return; + } + await this.renameRemoteSession(title); + } + + async retryVisibleMessage(text: string): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + const prepared = await runtime.generalCommands.retryMessage( + this.general.activeSession.sessionId || '', text, this.general.isBusy + ); + if (prepared) { + await runtime.generalConversation.sendMessage(); + } + return; + } + this.retryRemoteMessage(text); + } + + downloadVisibleFile(path: string): void { + if (this.requireRemoteRuntime().appShell.isGeneralChatVisible()) { + this.general.setStatus(RemoteI18n.t('generalChat.fileDownloadMock')); + return; + } + this.downloadRemoteFile(path); + } + + async selectVisibleModel(modelId: string): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + await runtime.settings.selectModel(modelId); + return; + } + await this.selectRemoteModel(modelId); + } + + startVisibleGeneralChat(): void { + const rawText = this.general.chatInput.trim(); + const text = rawText.length > 0 ? rawText : + (this.general.selectedImages.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); + if (text.length === 0 || this.general.isBusy) { + return; + } + if (this.general.serviceState === GeneralChatServiceState.Unconfigured) { + const statusText = GeneralChatServiceStatus.userMessage(this.general.serviceState); + this.general.setStatus(statusText); + this.showHomeToast(statusText); + return; + } + this.startGeneralChat(text); + } + + generalChatHomeStatusText(): string { + if (this.general.serviceState === GeneralChatServiceState.Ready || + this.general.serviceState === GeneralChatServiceState.Sending || + this.general.serviceState === GeneralChatServiceState.Streaming) { + return ''; + } + return GeneralChatServiceStatus.userMessage(this.general.serviceState, this.general.statusText); + } + + prepareNewGeneralChat(): void { + const runtime = this.requireRemoteRuntime(); + runtime.hooks.stopVoiceInput(); + runtime.generalConversation.stop(true); + runtime.generalDrafts.clearHome(); + this.general.clearComposer(); + this.general.clearActiveSession(); + this.resetGeneralTimeline(''); + runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome); + } + + onVisibleChatInputChange(route: AppRoute, value: string): void { + this.setChatInput(route, value); + if (this.isGeneralComposerRoute(route)) { + this.requireRemoteRuntime().generalDrafts.scheduleVisible(value); + } + } + + visibleGeneralChatDraftId(): string { + return this.requireRemoteRuntime().appShell.isGeneralChatVisible() ? + this.general.activeSession.sessionId || GENERAL_CHAT_HOME_DRAFT_ID : ''; + } + + async restoreGeneralChatDraft(draftId: string): Promise { + this.general.setChatInput(await this.requireRemoteRuntime().generalDrafts.restore(draftId)); + } + + latestUserMessageText(): string { + if (this.requireRemoteRuntime().appShell.isGeneralChatVisible()) { + return this.general.latestUserMessageText(); + } + const candidates = this.remote.persistedMessages.concat(this.remote.optimisticMessages); + for (let index = candidates.length - 1; index >= 0; index--) { + if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { + return candidates[index].text; + } + } + return ''; + } + + resetGeneralTimeline(sessionId: string): void { + this.requireRemoteRuntime().timeline.reset(sessionId); + this.syncGeneralTimeline(); + } + + syncGeneralTimeline(): void { + const runtime = this.requireRemoteRuntime(); + const state: ChatTimelineState = runtime.timeline.snapshotState(); + const projectedItems = runtime.timeline.viewState(false); + this.general.setTimelineProjection( + state.persistedMessages, + state.optimisticMessages, + state.activeTurn || RemoteUiState.emptyActiveTurn(), + false, + projectedItems + ); + const itemSummary = projectedItems.map((item: ChatTimelineItem) => { + const message = item.message; + return `${item.type}:${item.id}:${message ? message.status : ''}:${message ? message.text.length : 0}`; + }).join(','); + RemoteLogger.info(`general chat projection revision=${this.general.timelineRevision} persisted=${state.persistedMessages.length} active=${state.activeTurn ? state.activeTurn.id : 'none'} items=${itemSummary}`); + } + + showHomeToast(message: string): void { + const runtime = this.requireRemoteRuntime(); + if (!runtime.hooks.showToast(message)) { + this.setVisibleStatusText(message); + } + } + + private currentChatPollingCursor(): RemoteChatPollingCursor { + return { + pollVersion: this.knownPollVersionValue, + knownMessageCount: this.knownRemoteMessageCount, + knownModelCatalogVersion: this.knownModelCatalogVersion + }; + } + + private updateChatPollingCursor(pollVersion: number, knownMessageCount: number): void { + this.knownPollVersionValue = pollVersion; + this.knownRemoteMessageCount = knownMessageCount; + this.requireRemoteRuntime().polling.updateCursor({ + pollVersion, + knownMessageCount, + knownModelCatalogVersion: this.knownModelCatalogVersion + }); + } + + private async syncAfterRemoteTurnEnded(): Promise { + if (this.isSyncingAfterTurn) { + return; + } + this.isSyncingAfterTurn = true; + try { + await this.loadRemoteMessages(); + } finally { + this.isSyncingAfterTurn = false; + } + } + + private shortSessionId(sessionId: string): string { + return sessionId.length <= 8 ? sessionId : + sessionId.slice(0, 4) + '...' + sessionId.slice(sessionId.length - 4); + } + + routeCreatedRemoteSession(sessionId: string): void { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + this.remote.setConversationDismissed(false); + if (runtime.appShell.isRoute(AppRoute.RemoteCreate)) { + runtime.appShell.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); + return; + } + runtime.appShell.pushRoute(AppRoute.RemoteChat, sessionId); + } + + private routeRemoteSessionInPlace(sessionId: string): void { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + this.remote.setConversationDismissed(false); + const target = AppRouteContract.remoteSessionDestination(sessionId); + runtime.appShell.replaceRouteWithoutAnimation(target.name, target.routeParam().sessionId); + } + + private requireRemoteRuntime(): RemoteConversationDependencies { + if (!this.remoteRuntime) { + throw new Error('Remote conversation dependencies are not configured.'); + } + return this.remoteRuntime; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationViewModel.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationViewModel.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets new file mode 100644 index 000000000..fc8300b1f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets @@ -0,0 +1,92 @@ +import { FilePreviewRequest, FilePreviewTargetContext } from '../../model/FilePreviewTarget'; +import { SessionSummary } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { FileReferenceKind, FileTargetResolver } from '../../services/FileTargetResolver'; +import { RemoteWorkspaceFileClient } from '../../services/RemoteWorkspaceFileClient'; +import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; +import { FilePreviewState } from '../state/FilePreviewState'; +import { RemoteFilePreviewController } from './RemoteFilePreviewController'; + +export interface FilePreviewControllerHooks { + readonly remoteAvailable: () => boolean; + readonly activeSession: () => SessionSummary; + readonly workspacePath: () => string; + readonly openExternalLink: (reference: string) => Promise; + readonly onGeneralStatus: (statusText: string) => void; + readonly onRemoteStatus: (statusText: string) => void; +} + +/** Owns file-preview routing, target validity and the underlying remote file load. */ +export class FilePreviewController { + private readonly state: FilePreviewState; + private readonly hooks: FilePreviewControllerHooks; + private readonly loader: RemoteFilePreviewController; + private controlTargetEpoch: number = 1; + + constructor( + client: RemoteWorkspaceFileClient, + state: FilePreviewState, + hooks: FilePreviewControllerHooks + ) { + this.state = state; + this.hooks = hooks; + this.loader = new RemoteFilePreviewController( + client, + state, + hooks.remoteAvailable, + (): number => this.controlTargetEpoch + ); + } + + open(route: AppRoute, request: FilePreviewRequest): void { + const activeSession = this.hooks.activeSession(); + const context = new FilePreviewTargetContext( + activeSession.sessionId, + activeSession.workspacePath || this.hooks.workspacePath(), + this.controlTargetEpoch + ); + const resolution = FileTargetResolver.resolve(request.reference, request.label, context); + if (resolution.kind === FileReferenceKind.HttpUrl) { + void this.openExternalLink(route, request.reference); + return; + } + if (route !== AppRoute.RemoteChat) { + this.hooks.onGeneralStatus(RemoteI18n.t('generalChat.filePreviewUnavailable')); + return; + } + if (resolution.kind !== FileReferenceKind.RemoteWorkspaceFile || !resolution.target) { + return; + } + void this.loader.open(resolution.target); + } + + close(): void { + this.loader.close(); + } + + refresh(): void { + void this.loader.refresh(); + } + + openLink(reference: string, label: string): void { + this.open(AppRoute.RemoteChat, new FilePreviewRequest(reference, label)); + } + + invalidate(): void { + this.controlTargetEpoch += 1; + this.loader.close(); + } + + private async openExternalLink(route: AppRoute, reference: string): Promise { + const opened = await this.hooks.openExternalLink(reference); + if (opened) { + return; + } + const statusText = RemoteI18n.t('errors.operationFailed'); + if (AppRouteContract.isGeneralComposerRoute(route)) { + this.hooks.onGeneralStatus(statusText); + return; + } + this.hooks.onRemoteStatus(statusText); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/GeneralChatConversationViewModel.ets similarity index 95% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/GeneralChatConversationViewModel.ets index cd489b440..024818043 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/GeneralChatConversationViewModel.ets @@ -8,7 +8,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; import { Encoding } from '../../services/Encoding'; import { ConversationViewModel } from './ConversationViewModel'; -import { GeneralChatPageState } from './GeneralChatPageState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; @@ -22,26 +22,12 @@ import { } from '../../services/general-chat/GeneralChatPort'; import { RemoteLogger } from '../../services/RemoteLogger'; -export class GeneralChatConversationViewModelHooks { +export interface GeneralChatConversationViewModelHooks { readonly isVisible: (sessionId: string) => boolean; readonly currentActiveTurnId: () => string; readonly latestUserMessageText: () => string; readonly syncTimeline: () => void; readonly refreshSessions: () => void; - - constructor( - isVisible: (sessionId: string) => boolean, - currentActiveTurnId: () => string, - latestUserMessageText: () => string, - syncTimeline: () => void, - refreshSessions: () => void - ) { - this.isVisible = isVisible; - this.currentActiveTurnId = currentActiveTurnId; - this.latestUserMessageText = latestUserMessageText; - this.syncTimeline = syncTimeline; - this.refreshSessions = refreshSessions; - } } /** Owns General Chat stream state and publishes all updates through ConversationViewModel. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets similarity index 78% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets index fd029d22e..8228df45d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets @@ -5,7 +5,7 @@ import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; -export class RemoteActivityViewModelHooks { +export interface RemoteActivityViewModelHooks { readonly isConnected: () => boolean; readonly isBusy: () => boolean; readonly hasRemoteBinding: () => boolean; @@ -20,38 +20,6 @@ export class RemoteActivityViewModelHooks { readonly onPoll: () => Promise; readonly onReconnect: () => Promise; readonly onRestoreSession: (session: SessionSummary) => Promise; - - constructor( - isConnected: () => boolean, - isBusy: () => boolean, - hasRemoteBinding: () => boolean, - isRemoteChat: () => boolean, - activeSession: () => SessionSummary, - onConnectionState: (state: string) => void, - onStatus: (status: string) => void, - onConnectionError: (err: Object) => Promise, - onStopHeartbeat: () => void, - onStartPolling: () => void, - onStopPolling: () => void, - onPoll: () => Promise, - onReconnect: () => Promise, - onRestoreSession: (session: SessionSummary) => Promise - ) { - this.isConnected = isConnected; - this.isBusy = isBusy; - this.hasRemoteBinding = hasRemoteBinding; - this.isRemoteChat = isRemoteChat; - this.activeSession = activeSession; - this.onConnectionState = onConnectionState; - this.onStatus = onStatus; - this.onConnectionError = onConnectionError; - this.onStopHeartbeat = onStopHeartbeat; - this.onStartPolling = onStartPolling; - this.onStopPolling = onStopPolling; - this.onPoll = onPoll; - this.onReconnect = onReconnect; - this.onRestoreSession = onRestoreSession; - } } /** Owns foreground recovery, heartbeat health checks, and idempotent resume cancellation. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets similarity index 99% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets index a15d5ca37..40e66955c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets @@ -16,7 +16,7 @@ import { RemoteSessionController } from '../../services/RemoteSessionController' import { RemoteUiState } from '../../services/RemoteUiState'; import { QrScanService } from '../../services/QrScanService'; import { RemoteConnectionCoordinator, RemoteConnectionRequest } from '../../services/RemoteConnectionCoordinator'; -import { RemotePageState } from './RemotePageState'; +import { RemotePageState } from '../state/RemotePageState'; import { AppRoute } from '../navigation/AppRouteContract'; import { RemoteLogger } from '../../services/RemoteLogger'; @@ -30,7 +30,7 @@ export enum RemoteConnectionState { Disconnected = 'disconnected' } -export class RemoteConnectionViewModel { +export class RemoteConnectionController { private readonly pageState: RemotePageState; private readonly identity: MobileIdentityStore; private readonly pairing: RemotePairingPolicy; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteFilePreviewController.ets similarity index 95% rename from src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteFilePreviewController.ets index a3299361f..c2de26616 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteFilePreviewController.ets @@ -1,15 +1,15 @@ -import { FileInfo, ReadFileChunkResult, ReadFileResult } from '../model/RemoteModels'; +import { FileInfo, ReadFileChunkResult, ReadFileResult } from '../../model/RemoteModels'; import { FilePreviewPhase, FilePreviewRendererKind, FilePreviewState -} from '../pages/state/FilePreviewState'; -import { FilePreviewTarget } from '../pages/state/FilePreviewTarget'; -import { RemoteI18n } from '../i18n/RemoteI18n'; -import { Encoding } from './Encoding'; -import { FilePreviewErrorPolicy } from './FilePreviewErrorPolicy'; -import { FilePreviewPolicy } from './FilePreviewPolicy'; -import { RemoteWorkspaceFileClient } from './RemoteWorkspaceFileClient'; +} from '../state/FilePreviewState'; +import { FilePreviewTarget } from '../../model/FilePreviewTarget'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { Encoding } from '../../services/Encoding'; +import { FilePreviewErrorPolicy } from '../../services/FilePreviewErrorPolicy'; +import { FilePreviewPolicy } from '../../services/FilePreviewPolicy'; +import { RemoteWorkspaceFileClient } from '../../services/RemoteWorkspaceFileClient'; export class RemoteFilePreviewController { private readonly client: RemoteWorkspaceFileClient; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets similarity index 74% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets index fbedef6d7..42ab00f63 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets @@ -4,9 +4,9 @@ import { RemoteChatCommandController } from '../../services/RemoteChatCommandCon import { RemoteModelController } from '../../services/RemoteModelController'; import { RemoteSessionController } from '../../services/RemoteSessionController'; import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; -import { RemotePageState } from './RemotePageState'; +import { RemotePageState } from '../state/RemotePageState'; -export class RemoteSessionViewModelHooks { +export interface RemoteSessionViewModelHooks { readonly remoteAvailable: () => boolean; readonly isConnected: () => boolean; readonly isBusy: () => boolean; @@ -22,40 +22,6 @@ export class RemoteSessionViewModelHooks { readonly onLoadActiveMessages: () => Promise; readonly onRefreshSessions: () => Promise; readonly onSelectWorkspace: (path: string) => Promise; - - constructor( - remoteAvailable: () => boolean, - isConnected: () => boolean, - isBusy: () => boolean, - onBusy: (busy: boolean) => void, - onRouteChat: (sessionId: string) => void, - onRouteHome: () => void, - onStopPolling: () => void, - onStartPolling: () => void, - onResetTimeline: (sessionId: string) => void, - onClearRemoteFiles: () => void, - onKnownStateReset: () => void, - onLoadModelCatalog: (sessionId: string) => Promise, - onLoadActiveMessages: () => Promise, - onRefreshSessions: () => Promise, - onSelectWorkspace: (path: string) => Promise - ) { - this.remoteAvailable = remoteAvailable; - this.isConnected = isConnected; - this.isBusy = isBusy; - this.onBusy = onBusy; - this.onRouteChat = onRouteChat; - this.onRouteHome = onRouteHome; - this.onStopPolling = onStopPolling; - this.onStartPolling = onStartPolling; - this.onResetTimeline = onResetTimeline; - this.onClearRemoteFiles = onClearRemoteFiles; - this.onKnownStateReset = onKnownStateReset; - this.onLoadModelCatalog = onLoadModelCatalog; - this.onLoadActiveMessages = onLoadActiveMessages; - this.onRefreshSessions = onRefreshSessions; - this.onSelectWorkspace = onSelectWorkspace; - } } /** Owns remote session commands and their page lifecycle effects. */ @@ -158,26 +124,38 @@ export class RemoteSessionViewModel { currentWorkspacePath: string, onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat ): Promise { - await this.sessions.open( - item, - item.workspacePath || currentWorkspacePath, - this.hooks.isBusy(), - this.hooks.remoteAvailable(), - async (session: SessionSummary): Promise => { - this.hooks.onStopPolling(); - this.hooks.onResetTimeline(item.id); - this.hooks.onKnownStateReset(); - this.pageState.setHasMoreMessages(false); - this.files.clear(); - this.pageState.clearComposer(); - onRouteChat(item.id); - await this.hooks.onLoadModelCatalog(item.id); - await this.hooks.onLoadActiveMessages(); - if (this.pageState.activeSession.sessionId === session.sessionId) { - this.hooks.onStartPolling(); + const isBusy = this.hooks.isBusy(); + const remoteAvailable = this.hooks.remoteAvailable(); + if (isBusy || item.id.length === 0 || !remoteAvailable) { + return; + } + this.pageState.setPendingSessionId(item.id); + this.pageState.setConversationLoading(true); + onRouteChat(item.id); + try { + await this.sessions.open( + item, + item.workspacePath || currentWorkspacePath, + false, + true, + async (session: SessionSummary): Promise => { + this.hooks.onStopPolling(); + this.hooks.onResetTimeline(item.id); + this.hooks.onKnownStateReset(); + this.pageState.setHasMoreMessages(false); + this.files.clear(); + this.pageState.clearComposer(); + await this.hooks.onLoadModelCatalog(item.id); + await this.hooks.onLoadActiveMessages(); + if (this.pageState.activeSession.sessionId === session.sessionId) { + this.hooks.onStartPolling(); + } } - } - ); + ); + } finally { + this.pageState.setConversationLoading(false); + this.pageState.setPendingSessionId(''); + } } async deleteSession(item: RemoteSession, currentWorkspacePath: string): Promise { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets similarity index 86% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets index 87b9818dd..f6d3d475a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets @@ -2,9 +2,9 @@ import { RecentWorkspaceEntry, RemoteSession, WorkspaceInfo } from '../../model/ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; -import { RemotePageState } from './RemotePageState'; +import { RemotePageState } from '../state/RemotePageState'; -export class RemoteWorkspaceViewModelHooks { +export interface RemoteWorkspaceViewModelHooks { readonly isRemoteAvailable: () => boolean; readonly isBusy: () => boolean; readonly onBusy: (isBusy: boolean) => void; @@ -13,26 +13,6 @@ export class RemoteWorkspaceViewModelHooks { readonly onSessionsDiscovered: (sessions: RemoteSession[]) => void; readonly onRefreshSessions: () => Promise; readonly onConnectionFailure: (error: Object) => void; - - constructor( - isRemoteAvailable: () => boolean, - isBusy: () => boolean, - onBusy: (isBusy: boolean) => void, - onStatus: (statusText: string) => void, - onWorkspaceSelected: (workspace: WorkspaceInfo) => void, - onSessionsDiscovered: (sessions: RemoteSession[]) => void, - onRefreshSessions: () => Promise, - onConnectionFailure: (error: Object) => void - ) { - this.isRemoteAvailable = isRemoteAvailable; - this.isBusy = isBusy; - this.onBusy = onBusy; - this.onStatus = onStatus; - this.onWorkspaceSelected = onWorkspaceSelected; - this.onSessionsDiscovered = onSessionsDiscovered; - this.onRefreshSessions = onRefreshSessions; - this.onConnectionFailure = onConnectionFailure; - } } /** Owns the workspace/assistant picker workflows and their presentation state. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets new file mode 100644 index 000000000..02e90e9f0 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -0,0 +1,523 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CloudAccountDevice, CloudAccountRequestError, CloudAccountSession, CloudAccountClient } from '../../services/CloudAccountClient'; +import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { Encoding } from '../../services/Encoding'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore, + GeneralChatConfigUpdate, + GeneralChatConfigValidator, + GeneralChatModelSelectionPolicy +} from '../../services/general-chat/GeneralChatConfigStore'; +import { GeneralChatCloudConfigPolicy } from '../../services/general-chat/GeneralChatCloudConfigPolicy'; +import { GeneralChatServiceStatus } from '../../services/general-chat/GeneralChatServiceState'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemotePermissionMode } from '../../model/RemoteModels'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; + +export interface SettingsControllerHooks { + readonly probeConfiguration: (apiUrl: string, apiKey: string, modelName: string) => Promise; +} + +export interface CloudAccountSettingsHooks { + readonly deviceId: () => string; + readonly remoteAvailable: () => boolean; + readonly invalidatePreview: () => void; + readonly invalidateRemoteActivity: () => void; + readonly invalidateRemoteConnection: () => void; + readonly stopPolling: () => void; + readonly stopHeartbeat: () => void; + readonly startHeartbeat: () => void; + readonly resetTimeline: () => void; + readonly resetKnownRemoteState: () => void; + readonly closeSettings: () => void; + readonly closeConnectSheet: () => void; + readonly navigateRemoteHome: () => void; + readonly loadRecentWorkspaces: () => Promise; +} + +export interface CloudAccountSettingsDependencies { + readonly client: CloudAccountClient; + readonly sessionStore: CloudAccountSessionStore; + readonly sessionManager: RemoteSessionManager; + readonly remoteState: RemotePageState; + readonly hooks: CloudAccountSettingsHooks; +} + +/** Owns general-chat model service settings and their presentation projection. */ +export class SettingsController { + private readonly store: GeneralChatConfigStore; + private readonly state: GeneralChatPageState; + private readonly hooks: SettingsControllerHooks; + private readonly cloud?: CloudAccountSettingsDependencies; + private cloudSession?: CloudAccountSession; + private cloudRelayUrl: string = ''; + + constructor( + store: GeneralChatConfigStore, + state: GeneralChatPageState, + hooks: SettingsControllerHooks, + cloud?: CloudAccountSettingsDependencies + ) { + this.store = store; + this.state = state; + this.hooks = hooks; + this.cloud = cloud; + } + + async save( + apiUrl: string, + apiKey: string, + modelName: string, + clearApiKey: boolean + ): Promise { + const update = this.update(apiUrl, apiKey, modelName, clearApiKey); + try { + const validationError = await this.validate(update); + if (validationError.length > 0) { + return validationError; + } + if (!update.clearApiKey) { + const probeError = await this.probe(update); + if (probeError.length > 0) { + return probeError; + } + } + const catalogBeforeSave = await this.store.modelCatalog(); + const snapshot = await this.store.save(update); + if (GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel(catalogBeforeSave)) { + await this.store.selectLocalModel(); + } + this.apply(snapshot); + await this.refreshModelCatalog(); + return ''; + } catch (err) { + return ConnectionErrorPolicy.errorText(err); + } + } + + async test( + apiUrl: string, + apiKey: string, + modelName: string, + clearApiKey: boolean + ): Promise { + const update = this.update(apiUrl, apiKey, modelName, clearApiKey); + try { + const validationError = await this.validate(update); + if (validationError.length > 0) { + return validationError; + } + if (update.clearApiKey) { + return RemoteI18n.t('settings.modelService.testNeedsKey'); + } + return await this.probe(update); + } catch (err) { + return ConnectionErrorPolicy.errorText(err); + } + } + + apply(snapshot: GeneralChatConfigSnapshot): void { + this.state.setConfiguration( + snapshot.apiUrl, + snapshot.modelName, + snapshot.hasApiKey, + GeneralChatServiceStatus.fromConfiguration(snapshot.apiUrl, snapshot.modelName, snapshot.hasApiKey) + ); + } + + async refreshModelCatalog(): Promise { + const catalog = await this.store.modelCatalog(); + const selectedModelId = catalog.session_model_id || catalog.default_models.primary || ''; + this.state.setModelCatalog(catalog, selectedModelId); + const active = await this.store.activeSnapshot(); + this.state.setServiceState( + GeneralChatServiceStatus.fromConfiguration(active.apiUrl, active.modelName, active.hasApiKey) + ); + } + + async selectModel(modelId: string): Promise { + if (!await this.store.selectModel(modelId)) { + return false; + } + await this.refreshModelCatalog(); + return true; + } + + async initializeCloudAccount(context: Context): Promise { + const cloud = this.requireCloud(); + await cloud.sessionStore.init(context); + await this.restoreCloudAccountSession(); + } + + hasCloudAccountSession(): boolean { + return this.cloudSession !== undefined; + } + + async persistDelegatedAccountSession(): Promise { + if (this.cloudSession) { + return; + } + const cloud = this.requireCloud(); + const delegated = cloud.sessionManager.delegatedAccountSession(); + if (!delegated) { + return; + } + this.applyCloudAccountSession(delegated.session, delegated.relayUrl, delegated.session.userId); + await cloud.sessionStore.save({ + relayUrl: delegated.relayUrl, + username: delegated.session.userId, + token: delegated.session.token, + userId: delegated.session.userId, + masterKey: Encoding.bytesToBase64(delegated.session.masterKey) + }); + RemoteLogger.info('delegated account session persisted after room pairing'); + } + + async loginCloudAccount(relayUrl: string, username: string, password: string): Promise { + const cloud = this.requireCloud(); + RemoteLogger.info('cloud account UI login requested'); + const session = await cloud.client.login(relayUrl, username, password, cloud.hooks.deviceId()); + this.applyCloudAccountSession(session, relayUrl, username); + await cloud.sessionStore.save({ + relayUrl: relayUrl.trim(), username: username.trim(), token: session.token, userId: session.userId, + masterKey: Encoding.bytesToBase64(session.masterKey) + }); + await this.loadGeneralChatAccountModels(session, relayUrl); + RemoteLogger.info('cloud account credentials persisted, refreshing account devices'); + RemoteLogger.info(`cloud account login success user=${session.userId}`); + return session.userId; + } + + async syncCloudAccount(): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + if (!session || this.cloudRelayUrl.length === 0) { + throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); + } + let bundles: Object[]; + try { + bundles = await cloud.client.fetchSessions(this.cloudRelayUrl, session, 0); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + throw new Error(RemoteI18n.t('remote.settings.accountExpired')); + } + throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountSyncFailed')); + } + await this.loadGeneralChatAccountModels(session, this.cloudRelayUrl); + RemoteLogger.info(`cloud account backup sync completed count=${bundles.length}`); + return String(bundles.length); + } + + applyCloudAccountSession(session: CloudAccountSession, relayUrl: string, username: string): void { + const remoteState = this.requireCloud().remoteState; + this.cloudSession = session; + this.cloudRelayUrl = relayUrl.trim(); + remoteState.setAccountUserId(session.userId); + remoteState.setAccountUsername(username.trim()); + } + + async logoutCloudAccount(): Promise { + const cloud = this.requireCloud(); + cloud.hooks.invalidatePreview(); + if (cloud.remoteState.controlTargetType === 'account_device') { + this.resetAccountDeviceConnection(true); + } + this.cloudSession = undefined; + this.cloudRelayUrl = ''; + this.store.replaceAccountModels([]); + await this.refreshModelCatalog(); + await cloud.sessionStore.clear(); + cloud.remoteState.setAccountUserId(''); + cloud.remoteState.setAccountUsername(''); + cloud.remoteState.clearControlTarget(); + RemoteLogger.info('cloud account logout success'); + } + + async listCloudAccountDevices(): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + if (!session || this.cloudRelayUrl.length === 0) { + return []; + } + try { + return await cloud.client.listDevices(this.cloudRelayUrl, session); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + throw new Error(RemoteI18n.t('remote.settings.accountExpired')); + } + if (err instanceof CloudAccountRequestError && + (err.statusCode === 404 || err.statusCode === 503 || err.statusCode === 504)) { + throw new Error(RemoteI18n.t('remote.settings.deviceUnavailable')); + } + throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.deviceLoadFailed')); + } + } + + async getRemotePermissionMode(): Promise { + const cloud = this.requireCloud(); + if (!cloud.hooks.remoteAvailable()) { + throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); + } + return cloud.sessionManager.getPermissionMode(); + } + + async setRemotePermissionMode(mode: RemotePermissionMode): Promise { + const cloud = this.requireCloud(); + if (!cloud.hooks.remoteAvailable()) { + throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); + } + return cloud.sessionManager.setPermissionMode(mode); + } + + async restoreCloudTarget(targetDeviceId: string, targetDeviceName: string): Promise { + const targetId = targetDeviceId.trim(); + if (targetId.length === 0) { + return; + } + const remoteState = this.requireCloud().remoteState; + try { + const devices = await this.listCloudAccountDevices(); + const target = devices.find((device: CloudAccountDevice): boolean => device.deviceId === targetId); + if (!target || !target.online) { + const targetName = target?.deviceName || targetDeviceName || targetId; + remoteState.setControlTarget('account_device', targetId, targetName); + remoteState.setDesktopIdentity(targetName, targetId); + remoteState.setConnectionState('failed'); + remoteState.setStatusText(RemoteI18n.t('remote.settings.deviceUnavailable')); + return; + } + await this.selectCloudAccountDevice({ + deviceId: target.deviceId, + deviceName: target.deviceName || targetDeviceName || target.deviceId, + online: target.online, + lastSeenAt: target.lastSeenAt + }); + } catch (err) { + RemoteLogger.warn(`cloud target restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); + } + } + + async handleRemoteConnectionError(err: Object): Promise { + const remoteState = this.requireCloud().remoteState; + if (remoteState.controlTargetType !== 'account_device' || + !(err instanceof CloudAccountRequestError) || err.statusCode !== 401) { + return false; + } + await this.expireCloudAccountSession(); + remoteState.setStatusText(RemoteI18n.t('remote.settings.accountExpired')); + return true; + } + + async selectCloudAccountDevice(device: CloudAccountDevice, navigateHome: boolean = true): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + if (!device.online) { + throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); + } + if (!session || this.cloudRelayUrl.length === 0) { + throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); + } + const deviceId = device.deviceId.trim(); + if (deviceId.length === 0 || deviceId === cloud.hooks.deviceId()) { + return; + } + if (deviceId === cloud.remoteState.controlTargetDeviceId && cloud.remoteState.connectionState === 'connected') { + cloud.hooks.closeConnectSheet(); + if (navigateHome) { + cloud.hooks.navigateRemoteHome(); + } + return; + } + this.prepareAccountDeviceConnection(); + try { + const initialSync = await cloud.sessionManager.connectAccountDevice( + cloud.client, + this.cloudRelayUrl, + session, + deviceId + ); + cloud.remoteState.setControlTarget('account_device', deviceId, device.deviceName); + cloud.remoteState.setDesktopIdentity(device.deviceName, deviceId); + cloud.remoteState.setWorkspace( + initialSync.workspace.name, + initialSync.workspace.path, + initialSync.workspace.assistantId || '', + initialSync.workspace.gitBranch, + initialSync.workspace.workspaceKind || 'normal' + ); + cloud.remoteState.setSessions(initialSync.sessions, initialSync.hasMoreSessions); + cloud.remoteState.setAuthenticatedUserId(initialSync.authenticatedUserId); + cloud.remoteState.setConnectionState('connected'); + cloud.remoteState.setStatusText(RemoteI18n.t('connection.connected')); + cloud.hooks.closeSettings(); + cloud.hooks.closeConnectSheet(); + if (navigateHome) { + cloud.hooks.navigateRemoteHome(); + } + await cloud.sessionStore.save({ + relayUrl: this.cloudRelayUrl, + username: cloud.remoteState.accountUsername, + token: session.token, + userId: session.userId, + masterKey: Encoding.bytesToBase64(session.masterKey), + targetDeviceId: deviceId, + targetDeviceName: device.deviceName + }); + cloud.hooks.startHeartbeat(); + await cloud.hooks.loadRecentWorkspaces(); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + } + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setConnectionState('failed'); + const message = ConnectionErrorPolicy.errorText(err); + cloud.remoteState.setStatusText(message); + cloud.sessionManager.reset(); + throw new Error(message); + } finally { + cloud.remoteState.setLoadingHome(false); + cloud.remoteState.setBusy(false); + } + } + + private update( + apiUrl: string, + apiKey: string, + modelName: string, + clearApiKey: boolean + ): GeneralChatConfigUpdate { + return { apiUrl, apiKey, modelName, clearApiKey }; + } + + private async validate(update: GeneralChatConfigUpdate): Promise { + const snapshot = await this.store.snapshot(); + return GeneralChatConfigValidator.validate(update, snapshot.hasApiKey); + } + + private async probe(update: GeneralChatConfigUpdate): Promise { + const apiKey = await this.effectiveApiKey(update); + if (apiKey.length === 0) { + return RemoteI18n.t('settings.modelService.apiKeyRequired'); + } + try { + await this.hooks.probeConfiguration(update.apiUrl, apiKey, update.modelName); + return ''; + } catch (err) { + return ConnectionErrorPolicy.errorText(err); + } + } + + private async effectiveApiKey(update: GeneralChatConfigUpdate): Promise { + const directKey = update.apiKey.trim(); + if (directKey.length > 0) { + return directKey; + } + if (update.clearApiKey) { + return ''; + } + return (await this.store.accessToken()).trim(); + } + + private async restoreCloudAccountSession(): Promise { + const cloud = this.requireCloud(); + try { + const persisted = await cloud.sessionStore.load(); + if (!persisted) { + return; + } + const session: CloudAccountSession = { + token: persisted.token, + userId: persisted.userId, + masterKey: Encoding.base64ToBytes(persisted.masterKey) + }; + this.applyCloudAccountSession(session, persisted.relayUrl, persisted.username || session.userId); + await this.loadGeneralChatAccountModels(session, persisted.relayUrl); + } catch (err) { + RemoteLogger.warn(`cloud account restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); + await cloud.sessionStore.clear(); + } + } + + private async loadGeneralChatAccountModels(session: CloudAccountSession, relayUrl: string): Promise { + const cloud = this.requireCloud(); + this.store.replaceAccountModels([]); + try { + const blob = await cloud.client.fetchSettings(relayUrl, session); + if (!blob) { + this.store.replaceAccountModels([]); + await this.refreshModelCatalog(); + RemoteLogger.info('cloud model catalog is empty'); + return; + } + const models = GeneralChatCloudConfigPolicy.models(blob.plaintext); + this.store.replaceAccountModels(models); + await this.refreshModelCatalog(); + RemoteLogger.info(`cloud model catalog loaded count=${models.length} version=${blob.version}`); + } catch (err) { + await this.refreshModelCatalog(); + RemoteLogger.warn(`cloud model catalog load failed: ${err instanceof Error ? err.message : 'unknown error'}`); + } + } + + private async expireCloudAccountSession(): Promise { + const cloud = this.requireCloud(); + cloud.hooks.invalidatePreview(); + this.cloudSession = undefined; + this.cloudRelayUrl = ''; + await cloud.sessionStore.clear(); + cloud.remoteState.setAccountUserId(''); + cloud.remoteState.setAccountUsername(''); + if (cloud.remoteState.controlTargetType === 'account_device') { + this.resetAccountDeviceConnection(false); + } + } + + private prepareAccountDeviceConnection(): void { + const cloud = this.requireCloud(); + cloud.hooks.invalidatePreview(); + cloud.hooks.invalidateRemoteActivity(); + cloud.hooks.invalidateRemoteConnection(); + cloud.hooks.stopPolling(); + cloud.hooks.stopHeartbeat(); + cloud.remoteState.setConnectionState('reconnecting'); + cloud.remoteState.setLoadingHome(true); + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + cloud.remoteState.setBusy(true); + cloud.remoteState.setStatusText(RemoteI18n.t('remote.settings.deviceConnecting')); + cloud.remoteState.clearActiveSession(); + cloud.hooks.resetTimeline(); + cloud.hooks.resetKnownRemoteState(); + cloud.remoteState.setSessions([], false); + } + + private resetAccountDeviceConnection(clearWorkspace: boolean): void { + const cloud = this.requireCloud(); + cloud.hooks.invalidateRemoteActivity(); + cloud.hooks.stopPolling(); + cloud.hooks.stopHeartbeat(); + cloud.sessionManager.reset(); + cloud.remoteState.clearActiveSession(); + cloud.remoteState.setSessions([], false); + if (clearWorkspace) { + cloud.remoteState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + cloud.remoteState.setAuthenticatedUserId(''); + } + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setConnectionState('disconnected'); + } + + private requireCloud(): CloudAccountSettingsDependencies { + if (!this.cloud) { + throw new Error('Cloud account settings dependencies are not configured.'); + } + return this.cloud; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets index a83708868..1d438f430 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets @@ -1,4 +1,4 @@ -import { FilePreviewTarget, FilePreviewTargetContext } from '../pages/state/FilePreviewTarget'; +import { FilePreviewTarget, FilePreviewTargetContext } from '../model/FilePreviewTarget'; import { RemoteUiState } from './RemoteUiState'; export enum FileReferenceKind { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets index a891801a7..528e3bf95 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets @@ -1,4 +1,4 @@ -import { FilePreviewTargetContext } from '../pages/state/FilePreviewTarget'; +import { FilePreviewTargetContext } from '../model/FilePreviewTarget'; import { FileReferenceKind, FileTargetResolver } from './FileTargetResolver'; import { MarkdownParser, diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json index 124f69ff3..22d8c6438 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json @@ -60,6 +60,14 @@ "name": "connect_hero_surface", "value": "#F8FAFF" }, + { + "name": "connect_scan_accent", + "value": "#FFD021" + }, + { + "name": "modal_scrim", + "value": "#99000000" + }, { "name": "soft", "value": "#F4F3F0" diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json index 39e3e9d2c..9252b40ce 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json @@ -60,6 +60,14 @@ "name": "connect_hero_surface", "value": "#252522" }, + { + "name": "connect_scan_accent", + "value": "#FFD021" + }, + { + "name": "modal_scrim", + "value": "#99000000" + }, { "name": "soft", "value": "#2D2C28" diff --git a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets index 1af9cec0d..8db8af9f1 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets @@ -1,8 +1,9 @@ import { describe, expect, it } from '@ohos/hypium'; +import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; import { AppRootHostPort } from '../main/ets/pages/host/AppRootHostAdapter'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; -import { AppRootRuntime } from '../main/ets/pages/state/AppRootRuntime'; -import { FilePreviewRequest, FilePreviewTarget } from '../main/ets/pages/state/FilePreviewTarget'; +import { AppRootRuntime } from '../main/ets/pages/runtime/AppRootRuntime'; +import { FilePreviewRequest, FilePreviewTarget } from '../main/ets/model/FilePreviewTarget'; class FakeAppRootHost implements AppRootHostPort { externalLinks: string[] = []; @@ -30,41 +31,36 @@ class FakeAppRootHost implements AppRootHostPort { } class TestAppRootRuntime extends AppRootRuntime { - stopGeneralChatStreamCalls: number = 0; - constructor(host: AppRootHostPort = new FakeAppRootHost()) { super(host); } - - stopGeneralChatStream(cancelled: boolean, finalStatus: string = 'cancelled'): void { - this.stopGeneralChatStreamCalls += 1; - super.stopGeneralChatStream(cancelled, finalStatus); - } } export default function appRootLifecycleUnitTest() { describe('AppRootRuntime page hide lifecycle', () => { it('keeps backgrounded general chat running on page hide', 0, () => { const runtime = new TestAppRootRuntime(); + runtime.generalChatStreamLifecycleController.begin('session-1'); runtime.onPageHide(); - expect(runtime.stopGeneralChatStreamCalls).assertEqual(0); + expect(runtime.generalChatStreamLifecycleController.hasActiveStream()).assertTrue(); }); it('still performs general chat cleanup when the app truly disappears', 0, () => { const runtime = new TestAppRootRuntime(); + runtime.generalChatStreamLifecycleController.begin('session-1'); runtime.aboutToDisappear(); - expect(runtime.stopGeneralChatStreamCalls).assertEqual(1); + expect(runtime.generalChatStreamLifecycleController.hasActiveStream()).assertFalse(); }); it('routes HTTP Markdown links through the host without opening file preview', 0, async () => { const host = new FakeAppRootHost(); const runtime = new TestAppRootRuntime(host); - runtime.openFilePreview( + runtime.filePreviewController.open( AppRoute.ChatHome, new FilePreviewRequest('https://example.com/docs', 'docs') ); @@ -75,6 +71,22 @@ export default function appRootLifecycleUnitTest() { expect(runtime.filePreviewState.visible).assertFalse(); }); + it('reports external-link failures through the active conversation surface', 0, async () => { + const host = new FakeAppRootHost(); + host.externalLinkResult = false; + const runtime = new TestAppRootRuntime(host); + + runtime.filePreviewController.open( + AppRoute.ChatHome, + new FilePreviewRequest('https://example.com/failure', 'failure') + ); + await new Promise((resolve: () => void) => setTimeout(resolve, 0)); + + expect(runtime.generalChatPageState.conversation.statusText) + .assertEqual(RemoteI18n.t('errors.operationFailed')); + expect(runtime.remotePageState.conversation.statusText).assertEqual(''); + }); + it('closes preview before applying conversation navigation back', 0, () => { const runtime = new TestAppRootRuntime(); runtime.filePreviewState.begin(new FilePreviewTarget( @@ -92,7 +104,7 @@ export default function appRootLifecycleUnitTest() { 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 )); - runtime.invalidateFilePreviewTarget(); + runtime.filePreviewController.invalidate(); expect(runtime.filePreviewState.visible).assertFalse(); }); @@ -109,7 +121,7 @@ export default function appRootLifecycleUnitTest() { 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 )); - runtime.applyRemoteActiveSession({ + runtime.conversationController.applyRemoteActiveSession({ sessionId: 'session-1', title: 'Renamed session', workspacePath: '/workspace', @@ -117,7 +129,7 @@ export default function appRootLifecycleUnitTest() { }); expect(runtime.filePreviewState.visible).assertTrue(); - runtime.applyRemoteActiveSession({ + runtime.conversationController.applyRemoteActiveSession({ sessionId: 'session-2', title: 'Session 2', workspacePath: '/workspace', diff --git a/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets index 060199ace..0d4d2e856 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets @@ -1,8 +1,7 @@ import { describe, expect, it } from '@ohos/hypium'; import { AppRootHostPort } from '../main/ets/pages/host/AppRootHostAdapter'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; -import { AppRootRuntime } from '../main/ets/pages/state/AppRootRuntime'; -import { CloudAccountSession } from '../main/ets/services/CloudAccountClient'; +import { AppRootRuntime } from '../main/ets/pages/runtime/AppRootRuntime'; class FakeAppRootHost implements AppRootHostPort { attach(_context: Context, _uiContext: UIContext): void { @@ -21,21 +20,11 @@ class FakeAppRootHost implements AppRootHostPort { } } -class TestAppRootRuntime extends AppRootRuntime { - constructor() { - super(new FakeAppRootHost()); - } - - applySession(session: CloudAccountSession, relayUrl: string, username: string): void { - this.applyCloudAccountSession(session, relayUrl, username); - } -} - export default function appRootRuntimeStartupUnitTest() { describe('AppRootRuntime startup restore', () => { it('applies cloud credentials without selecting a remote target', 0, () => { - const runtime = new TestAppRootRuntime(); - runtime.applySession({ + const runtime = new AppRootRuntime(new FakeAppRootHost()); + runtime.settingsController.applyCloudAccountSession({ token: 'token-1', userId: 'user-1', masterKey: new Uint8Array(32) @@ -45,7 +34,7 @@ export default function appRootRuntimeStartupUnitTest() { expect(runtime.remotePageState.accountUsername).assertEqual('alice'); expect(runtime.remotePageState.controlTargetType).assertEqual('none'); expect(runtime.remotePageState.controlTargetDeviceId).assertEqual(''); - expect(runtime.currentRoute()).assertEqual(AppRoute.ChatHome); + expect(runtime.appShellViewModel.currentRoute()).assertEqual(AppRoute.ChatHome); }); }); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets index 113963738..4161ebe9f 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets @@ -7,8 +7,9 @@ import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; import { ChatMessage } from '../main/ets/model/RemoteModels'; -import { AppShellViewModel } from '../main/ets/pages/state/AppShellViewModel'; +import { AppShellViewModel } from '../main/ets/pages/viewmodel/AppShellViewModel'; import { AppNavigationBackAction } from '../main/ets/pages/navigation/AppRouteContract'; +import { WideLayoutGeometry } from '../main/ets/pages/layout/WideLayoutGeometry'; export default function architectureUnitTest() { describe('MobileArchitecture', () => { @@ -91,5 +92,15 @@ export default function architectureUnitTest() { expect(shell.currentRoute()).assertEqual(AppRoute.RemoteHome); expect(shell.navigationStack.getAllPathName().length).assertEqual(1); }); + + it('keeps wide layout geometry pure and deterministic', 0, () => { + expect(WideLayoutGeometry.detailOffset(false, 24, 8)).assertEqual(24); + expect(WideLayoutGeometry.detailOffset(true, 24, 8)).assertEqual(8); + expect(WideLayoutGeometry.detailWidth(true, 900, 1200)).assertEqual(1200); + expect(WideLayoutGeometry.collapsedVisualBias(true, 0, 1100, 920, 72)).assertEqual(72); + expect(WideLayoutGeometry.collapsedVisualBias(false, 0, 1100, 920, 72)).assertEqual(0); + expect(WideLayoutGeometry.areaLength('1080')).assertEqual(1080); + expect(WideLayoutGeometry.areaLength('invalid')).assertEqual(0); + }); }); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets index 1c6a270ef..57de0bb8d 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -80,9 +80,12 @@ import { } from '../main/ets/services/VoiceInputLifecycleController'; import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; import { AppShellState } from '../main/ets/pages/state/AppShellState'; +import { ConversationCoreState } from '../main/ets/pages/state/ConversationCoreState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../main/ets/pages/state/RemoteCreateSessionState'; import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; +import { ConversationController } from '../main/ets/pages/viewmodel/ConversationController'; import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES @@ -564,6 +567,81 @@ export default function conversationStateUnitTest() { }); }); + describe('ConversationController', () => { + it('keeps composer state isolated while the visible route changes', 0, () => { + const general = new GeneralChatPageState(); + const remote = new RemotePageState(); + const remoteCreate = new RemoteCreateSessionState(); + let route = AppRoute.ChatHome; + const controller = new ConversationController( + general, + remote, + remoteCreate, + { currentRoute: (): AppRoute => route } + ); + + controller.setChatInput(AppRoute.ChatHome, 'general draft'); + controller.setChatInput(AppRoute.RemoteChat, 'remote draft'); + controller.setChatInput(AppRoute.RemoteCreate, 'create draft'); + + expect(controller.visibleChatInput()).assertEqual('general draft'); + route = AppRoute.RemoteChat; + expect(controller.visibleChatInput()).assertEqual('remote draft'); + route = AppRoute.RemoteCreate; + expect(controller.visibleChatInput()).assertEqual('create draft'); + expect(general.chatInput).assertEqual('general draft'); + expect(remote.chatInput).assertEqual('remote draft'); + }); + + it('clears voice state for every conversation surface on teardown', 0, () => { + const general = new GeneralChatPageState(); + const remote = new RemotePageState(); + const remoteCreate = new RemoteCreateSessionState(); + const controller = new ConversationController( + general, + remote, + remoteCreate, + { currentRoute: (): AppRoute => AppRoute.RemoteCreate } + ); + controller.setVoiceListening(AppRoute.ChatHome, true); + controller.setVoiceListening(AppRoute.RemoteChat, true); + controller.setVoiceListening(AppRoute.RemoteCreate, true); + + controller.clearAllVoiceListening(); + + expect(general.isVoiceListening).assertFalse(); + expect(remote.isVoiceListening).assertFalse(); + expect(remoteCreate.isVoiceListening).assertFalse(); + }); + }); + + describe('ConversationCoreState', () => { + it('owns shared conversation data while keeping product surfaces isolated', 0, () => { + const general = new ConversationCoreState('chat'); + const remote = new ConversationCoreState('code'); + general.setActiveSession({ + sessionId: 'general-core', title: 'General', workspacePath: '', agentType: 'code' + }); + remote.setActiveSession({ + sessionId: 'remote-core', title: 'Remote', workspacePath: '/workspace', agentType: 'code' + }); + general.setChatInput('general draft'); + remote.setChatInput('remote draft'); + general.setBusy(true); + + expect(general.activeSession.agentType).assertEqual('chat'); + expect(remote.activeSession.agentType).assertEqual('code'); + expect(general.chatInput).assertEqual('general draft'); + expect(remote.chatInput).assertEqual('remote draft'); + expect(remote.isBusy).assertFalse(); + + general.clearActiveSession(); + expect(general.activeSession.sessionId).assertEqual(''); + expect(remote.activeSession.sessionId).assertEqual('remote-core'); + expect(remote.chatInput).assertEqual('remote draft'); + }); + }); + describe('GeneralChatPageState', () => { it('projects configuration, busy state, and status text', 0, () => { const state = new GeneralChatPageState(); @@ -797,6 +875,9 @@ export default function conversationStateUnitTest() { }); state.setTimelineProjection([userMessage], [], activeTurn, false, timelineItems); state.setModelCatalog(modelCatalog, 'model-a'); + state.setConversationLoading(true); + state.setPendingSessionId('remote-2'); + state.setConversationDismissed(true); timelineItems.length = 0; expect(state.activeSession.sessionId).assertEqual('remote-1'); @@ -805,6 +886,14 @@ export default function conversationStateUnitTest() { expect(state.hasRunningActiveTurn()).assertTrue(); expect(state.modelCatalog.version).assertEqual(2); expect(state.selectedModelId).assertEqual('model-a'); + expect(state.isLoadingConversation).assertTrue(); + expect(state.pendingSessionId).assertEqual('remote-2'); + expect(state.isConversationDismissed).assertTrue(); + + state.clearActiveSession(); + expect(state.pendingSessionId).assertEqual(''); + expect(state.isLoadingConversation).assertFalse(); + expect(state.isConversationDismissed).assertFalse(); }); it('copies nested remote session projection and replaces streaming turn snapshots', 0, () => { @@ -932,6 +1021,7 @@ export default function conversationStateUnitTest() { remote.setActiveSession({ sessionId: 'remote-session', title: 'Remote', workspacePath: '/repo', agentType: 'code' }); + remote.setConversationLoading(true); const general = new GeneralChatPageState(); general.setChatInput('general draft'); general.setActiveSession({ @@ -942,6 +1032,7 @@ export default function conversationStateUnitTest() { expect(remoteProjection.surface).assertEqual(ChatSurface.Remote); expect(remoteProjection.chatInput).assertEqual('remote draft'); expect(remoteProjection.activeSession.sessionId).assertEqual('remote-session'); + expect(remoteProjection.isLoadingConversation).assertTrue(); const generalProjection = ConversationViewState.project(AppRoute.ChatHome, remote, general, 'Configure model'); expect(generalProjection.surface).assertEqual(ChatSurface.General); diff --git a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets index 3fbd4c6b5..97a364d5b 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets @@ -189,6 +189,18 @@ export default function lifecycleUnitTest() { expect(state.showSettings).assertFalse(); expect(state.showConnectSheet).assertFalse(); }); + + it('mirrors the resolved layout mode for runtime branching', 0, () => { + const state = new AppShellState(); + + expect(state.wideLayout).assertFalse(); + state.setWideLayout(true); + expect(state.wideLayout).assertTrue(); + + state.setSidebarVisible(true); + state.closeGlobalSurfaces(); + expect(state.wideLayout).assertTrue(); + }); }); describe('AsyncLifecycleGate', () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets index eaf64f2b0..471cc8ef7 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -76,7 +76,7 @@ import { MessageFileReferenceProjectionCache, MessageFileReferenceProjector } from '../main/ets/services/MessageFileReferenceProjector'; -import { RemoteFilePreviewController } from '../main/ets/services/RemoteFilePreviewController'; +import { RemoteFilePreviewController } from '../main/ets/pages/viewmodel/RemoteFilePreviewController'; import { RemoteHeartbeatController, RemoteHeartbeatScheduler } from '../main/ets/services/RemoteHeartbeatController'; import { RemoteModelClient, @@ -107,18 +107,18 @@ import { FilePreviewRendererKind, FilePreviewState } from '../main/ets/pages/state/FilePreviewState'; -import { FilePreviewTarget, FilePreviewTargetContext } from '../main/ets/pages/state/FilePreviewTarget'; +import { FilePreviewTarget, FilePreviewTargetContext } from '../main/ets/model/FilePreviewTarget'; import { FilePreviewPlacement, FilePreviewPlacementPolicy -} from '../main/ets/pages/state/FilePreviewPlacementPolicy'; +} from '../main/ets/pages/policy/FilePreviewPlacementPolicy'; import { ConversationLayoutCrease, ConversationLayoutPolicy -} from '../main/ets/pages/state/ConversationLayoutPolicy'; -import { SessionActionPolicy, SessionActionScope } from '../main/ets/pages/state/SessionActionPolicy'; -import { ConversationSessionFilterPolicy } from '../main/ets/pages/state/ConversationSessionFilterPolicy'; -import { ConversationModelPresentationPolicy } from '../main/ets/pages/state/ConversationModelPresentationPolicy'; +} from '../main/ets/pages/policy/ConversationLayoutPolicy'; +import { SessionActionPolicy, SessionActionScope } from '../main/ets/pages/policy/SessionActionPolicy'; +import { ConversationSessionFilterPolicy } from '../main/ets/pages/policy/ConversationSessionFilterPolicy'; +import { ConversationModelPresentationPolicy } from '../main/ets/pages/policy/ConversationModelPresentationPolicy'; import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES @@ -788,7 +788,7 @@ export default function remoteControllersUnitTest() { expect(state.selectedWorkspaceName).assertEqual('BitFun'); }); - it('freezes the selected creation target and keeps workspace chats as Claw sessions', 0, () => { + it('freezes the selected creation target and pairs a workspace with the code agent', 0, () => { const state = new RemoteCreateSessionState(); state.prepare('desktop-b', 'Desktop B'); state.setWorkspaces([{ @@ -802,6 +802,17 @@ export default function remoteControllersUnitTest() { expect(context.deviceId).assertEqual('desktop-b'); expect(context.workspacePath).assertEqual('/workspace/BitFun'); + expect(context.agentType).assertEqual('code'); + }); + + it('keeps the chat option on the assistant agent so the desktop binds its assistant workspace', 0, () => { + const state = new RemoteCreateSessionState(); + state.prepare('desktop-b', 'Desktop B'); + state.selectWorkspace(undefined); + + const context = state.submissionContext(); + + expect(context.workspacePath).assertEqual(''); expect(context.agentType).assertEqual('Claw'); }); }); @@ -1677,6 +1688,14 @@ export default function remoteControllersUnitTest() { expect(remoteChat.name).assertEqual(AppRoute.RemoteChat); expect(remoteChat.routeParam().sessionId).assertEqual('remote-1'); }); + + it('routes an in-place remote session selection to an explicit chat destination', 0, () => { + const target = AppRouteContract.remoteSessionDestination('remote-session-1'); + + expect(target.name).assertEqual(AppRoute.RemoteChat); + expect(target.hasSessionParam()).assertTrue(); + expect(target.routeParam().sessionId).assertEqual('remote-session-1'); + }); }); describe('SessionActionPolicy', () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index e6b32f7f9..d083ef655 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -20,6 +20,7 @@ import { GeneralChatCommandClient, GeneralChatCommandController } from '../main/ import { GeneralChatConfigSnapshot, GeneralChatConfigStore, + GeneralChatConfigUpdate, GeneralChatConfigValidator, GeneralChatModelSelectionPolicy } from '../main/ets/services/general-chat/GeneralChatConfigStore'; @@ -84,6 +85,7 @@ import { import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; import { AppShellState } from '../main/ets/pages/state/AppShellState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { SettingsController } from '../main/ets/pages/viewmodel/SettingsController'; import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; import { @@ -217,6 +219,54 @@ function modelProviderRecordedResponse(statusCode: number, body: string): ModelP return response; } +class InMemorySettingsConfigStore extends GeneralChatConfigStore { + snapshotResult: GeneralChatConfigSnapshot = { + apiUrl: '', + modelName: '', + hasApiKey: false + }; + accessTokenResult: string = ''; + modelCatalogResults: RemoteModelCatalog[] = []; + modelCatalogCalls: number = 0; + saveRequests: GeneralChatConfigUpdate[] = []; + selectLocalModelCalls: number = 0; + + async snapshot(): Promise { + return this.snapshotResult; + } + + async save(update: GeneralChatConfigUpdate): Promise { + this.saveRequests.push(update); + this.snapshotResult = { + apiUrl: update.apiUrl.trim(), + modelName: update.modelName.trim(), + hasApiKey: !update.clearApiKey + }; + return this.snapshotResult; + } + + async accessToken(): Promise { + return this.accessTokenResult; + } + + async modelCatalog(): Promise { + const index = Math.min(this.modelCatalogCalls, this.modelCatalogResults.length - 1); + this.modelCatalogCalls += 1; + if (index >= 0) { + return this.modelCatalogResults[index]; + } + return { version: 1, models: [], default_models: {} }; + } + + async selectLocalModel(): Promise { + this.selectLocalModelCalls += 1; + } + + async activeSnapshot(): Promise { + return this.snapshotResult; + } +} + export default function transportAndGeneralChatUnitTest() { describe('RemoteDescriptorParser', () => { it('parses hash route URLs', 0, () => { @@ -748,6 +798,66 @@ export default function transportAndGeneralChatUnitTest() { }); }); + describe('SettingsController', () => { + it('tests model configuration with the stored key when the form keeps it unchanged', 0, async () => { + const store = new InMemorySettingsConfigStore(); + store.snapshotResult = { + apiUrl: 'https://chat.example.com', + modelName: 'model-a', + hasApiKey: true + }; + store.accessTokenResult = ' stored-key '; + let probedApiKey = ''; + const controller = new SettingsController(store, new GeneralChatPageState(), { + probeConfiguration: async (_apiUrl: string, apiKey: string, _modelName: string): Promise => { + probedApiKey = apiKey; + } + }); + + const error = await controller.test('https://chat.example.com', '', 'model-a', false); + + expect(error).assertEqual(''); + expect(probedApiKey).assertEqual('stored-key'); + }); + + it('saves the first local model and projects its catalog into page state', 0, async () => { + const store = new InMemorySettingsConfigStore(); + store.modelCatalogResults = [ + { version: 1, models: [], default_models: {} }, + { + version: 2, + models: [{ + id: 'local-general-chat', + name: 'model-a', + provider: 'local', + base_url: 'https://chat.example.com', + model_name: 'model-a', + enabled: true, + capabilities: ['text_chat'] + }], + default_models: { primary: 'local-general-chat' }, + session_model_id: 'local-general-chat' + } + ]; + const state = new GeneralChatPageState(); + const controller = new SettingsController(store, state, { + probeConfiguration: async (_apiUrl: string, _apiKey: string, _modelName: string): Promise => { + } + }); + + const error = await controller.save( + 'https://chat.example.com', 'new-key', 'model-a', false + ); + + expect(error).assertEqual(''); + expect(store.saveRequests.length).assertEqual(1); + expect(store.selectLocalModelCalls).assertEqual(1); + expect(state.apiUrl).assertEqual('https://chat.example.com'); + expect(state.conversation.selectedModelId).assertEqual('local-general-chat'); + expect(state.serviceState).assertEqual(GeneralChatServiceState.Ready); + }); + }); + describe('GeneralChatModelSelectionPolicy', () => { it('keeps an existing cloud selection when a local model is saved', 0, () => { const shouldActivateLocal = GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel({ From 61a957ba0a9f69b6843b7fb6ff98eabea85e0e2b Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 7 Aug 2026 17:22:23 +0800 Subject: [PATCH 02/65] chore(scripts): check HarmonyOS architecture boundaries The MVVM split only holds if the import direction is enforced. Add `pnpm run harmony:architecture`, which fails when services import pages, when components import view models, when the page graph gains a cycle, or when action and hook interfaces are passed as anything but typed object literals. Co-Authored-By: Claude Opus 5 --- package.json | 1 + scripts/check-harmonyos-architecture.mjs | 341 +++++++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 scripts/check-harmonyos-architecture.mjs diff --git a/package.json b/package.json index 71bceae0b..0e6d17781 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "models-dev:check": "node scripts/update-models-dev-snapshot.mjs --check", "models-dev:update": "node scripts/update-models-dev-snapshot.mjs", "check:build-prereqs": "node scripts/check-build-prereqs.mjs", + "harmony:architecture": "node scripts/check-harmonyos-architecture.mjs", "check:core-boundaries": "node scripts/check-core-boundaries.mjs", "check:core-boundaries:test": "node --test scripts/check-core-boundaries.test.mjs", "check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs && node --test scripts/check-github-config.test.mjs", diff --git a/scripts/check-harmonyos-architecture.mjs b/scripts/check-harmonyos-architecture.mjs new file mode 100644 index 000000000..2ccdeff49 --- /dev/null +++ b/scripts/check-harmonyos-architecture.mjs @@ -0,0 +1,341 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..'); +const etsRoot = path.join(repoRoot, 'src/apps/mobile/harmonyos/entry/src/main/ets'); +const pagesRoot = path.join(etsRoot, 'pages'); + +function walkEts(root) { + const entries = fs.readdirSync(root, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) { + files.push(...walkEts(entryPath)); + } else if (entry.isFile() && entry.name.endsWith('.ets')) { + files.push(entryPath); + } + } + return files; +} + +function relative(file) { + return path.relative(repoRoot, file).split(path.sep).join('/'); +} + +function imports(file) { + const source = fs.readFileSync(file, 'utf8'); + const specs = [...source.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((match) => match[1]); + return specs.map((spec) => { + if (!spec.startsWith('.')) { + return spec; + } + return path.relative(etsRoot, path.resolve(path.dirname(file), spec)).split(path.sep).join('/'); + }); +} + +function filesUnder(root) { + return walkEts(root).sort(); +} + +const allPages = filesUnder(pagesRoot); +const services = filesUnder(path.join(etsRoot, 'services')); +const components = allPages.filter((file) => file.includes(`${path.sep}pages${path.sep}components${path.sep}`)); +const viewmodels = allPages.filter((file) => file.includes(`${path.sep}pages${path.sep}viewmodel${path.sep}`)); + +const serviceToPages = services + .filter((file) => imports(file).some((spec) => spec === 'pages' || spec.startsWith('pages/'))) + .map(relative); +const componentToViewmodel = components + .filter((file) => imports(file).some((spec) => spec === 'pages/viewmodel' || spec.startsWith('pages/viewmodel/'))) + .map(relative); +const viewmodelToComponents = viewmodels + .filter((file) => imports(file).some((spec) => spec === 'pages/components' || spec.startsWith('pages/components/'))) + .map(relative); +const v1Components = allPages + .filter((file) => /^\s*@Component\s*$/m.test(fs.readFileSync(file, 'utf8'))) + .map(relative); +const positionalActionConstructors = allPages + .filter((file) => /export\s+class\s+\w+(?:Actions|Hooks)\b/.test(fs.readFileSync(file, 'utf8')) && + /\bconstructor\s*\(/.test(fs.readFileSync(file, 'utf8'))) + .map(relative); +const sharedConversationFields = [ + 'sessions', + 'activeSession', + 'persistedMessages', + 'optimisticMessages', + 'activeTurnMessage', + 'hasMoreMessages', + 'timelineItems', + 'timelineRevision', + 'isBusy', + 'modelCatalog', + 'selectedModelId', + 'statusText', + 'chatInput', + 'selectedImages', + 'isVoiceListening' +]; +const conversationPageStateFiles = [ + path.join(pagesRoot, 'state/GeneralChatPageState.ets'), + path.join(pagesRoot, 'state/RemotePageState.ets') +]; +const duplicatedConversationTraceFields = conversationPageStateFiles.flatMap((file) => { + const source = fs.readFileSync(file, 'utf8'); + return sharedConversationFields + .filter((field) => new RegExp(`@Trace\\s+${field}\\s*:`).test(source)) + .map((field) => `${relative(file)}:${field}`); +}); +const appRootRuntimeFile = path.join(pagesRoot, 'runtime/AppRootRuntime.ets'); +const appRootRuntimeSource = fs.readFileSync(appRootRuntimeFile, 'utf8'); +const appRootRuntimeLines = appRootRuntimeSource.split(/\r?\n/).length - 1; +const appRootPresentationFile = path.join(pagesRoot, 'components/AppRootPresentation.ets'); +const appRootPresentationSource = fs.readFileSync(appRootPresentationFile, 'utf8'); +const appRootPresentationLines = appRootPresentationSource.split(/\r?\n/).length - 1; +const requiredPresentationFiles = [ + 'components/AppRootOverlaySurfaces.ets', + 'components/ChatMessageChrome.ets', + 'components/ConnectManualPairingOverlay.ets', + 'components/ConversationRouteSurface.ets', + 'components/ToolInteractionPanels.ets', + 'components/WideConversationHost.ets', + 'components/remote/RemoteSurfaceHost.ets' +]; +const missingPresentationFiles = requiredPresentationFiles + .filter((file) => !fs.existsSync(path.join(pagesRoot, file))); +const componentLineBudgets = [ + ['components/ChatMessageBubble.ets', 1000], + ['components/ConnectView.ets', 700], + ['components/ToolStatusList.ets', 1120] +]; +const extractedFilePreviewMethods = [ + 'openFilePreview', + 'closeFilePreview', + 'refreshFilePreview', + 'openFilePreviewLink', + 'invalidateFilePreviewTarget' +].filter((method) => new RegExp(`^\\s{2}${method}\\s*\\(`, 'm').test(appRootRuntimeSource)); +const extractedSettingsMethods = [ + 'saveGeneralChatConfig', + 'testGeneralChatConfig', + 'validateGeneralChatConfig', + 'probeGeneralChatConfig', + 'effectiveGeneralChatApiKey', + 'applyGeneralChatConfig', + 'refreshGeneralChatModelCatalog' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedCloudAccountMethods = [ + 'persistDelegatedAccountSession', + 'loginCloudAccount', + 'restoreCloudAccountSession', + 'loadGeneralChatAccountModels', + 'syncCloudAccount', + 'applyCloudAccountSession', + 'logoutCloudAccount', + 'listCloudAccountDevices', + 'getRemotePermissionMode', + 'setRemotePermissionMode', + 'restoreCloudTarget', + 'expireCloudAccountSession', + 'handleRemoteConnectionError', + 'selectCloudAccountDevice' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+|protected\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedConversationMethods = [ + 'isGeneralComposerRoute', + 'visibleChatInput', + 'visibleSelectedImages', + 'visibleVoiceListening', + 'setChatInputForRoute', + 'setSelectedImagesForRoute', + 'addSelectedImagesForRoute', + 'removeSelectedImageForRoute', + 'clearComposerForRoute', + 'setVoiceListeningForRoute', + 'setAllVoiceListening', + 'voiceInputSnapshot', + 'visibleChatBusy', + 'visibleStatusText', + 'setVisibleStatusText' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedRemoteConversationMethods = [ + 'sendChatMessage', + 'stopActiveTask', + 'renameActiveSession', + 'copyMessage', + 'downloadFile', + 'retryMessage', + 'approveTool', + 'rejectTool', + 'cancelTool', + 'answerQuestion', + 'resetChatTimeline', + 'syncChatTimelineFromStore', + 'startPolling', + 'currentChatPollingCursor', + 'updateChatPollingCursor', + 'applyChatSessionSnapshot', + 'hasRunningActiveTurn', + 'projectedTimelineItems', + 'syncAfterTurnEnded' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedRemoteCreateMethods = [ + 'createSession', + 'openRemoteCreateSession', + 'closeRemoteCreateSession', + 'loadRemoteCreateChoices', + 'loadRemoteCreateModelCatalog', + 'loadRemoteCreateDevices', + 'loadRemoteCreateWorkspaces', + 'toggleRemoteCreateDevices', + 'toggleRemoteCreateWorkspaces', + 'selectRemoteCreateDevice', + 'selectRemoteCreateWorkspace', + 'submitRemoteCreateSession', + 'createSessionInWorkspace', + 'openSession', + 'applyRemoteActiveSession', + 'deleteSession' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedGeneralConversationMethods = [ + 'openHomeSession', + 'openHomeSessionInPlace', + 'deleteHomeSession', + 'activeGeneralChatAsRemoteSession', + 'activeGeneralUploadedFileCount', + 'archiveHomeSession', + 'exportHomeSession', + 'openGeneralSession', + 'startGeneralChat', + 'sendVisibleChatMessage', + 'stopActiveChatTask', + 'closeActiveChat', + 'renameVisibleSession', + 'retryVisibleMessage', + 'downloadVisibleFile', + 'selectModel', + 'sendGeneralChatMessage', + 'stopGeneralChatStream', + 'startVisibleGeneralChat', + 'generalChatHomeStatusText', + 'prepareNewGeneralChat', + 'onVisibleChatInputChange', + 'visibleGeneralChatDraftId', + 'restoreGeneralChatDraft', + 'latestUserMessageText', + 'showHomeToast', + 'resetGeneralChatTimeline', + 'syncGeneralChatTimelineFromStore' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedRemoteConnectionForwards = [ + 'applyWorkspace', + 'applyRemotePairingProjection', + 'ensureRemoteAvailable', + 'setRemoteConnectionState', + 'setRemoteUrl', + 'setRemoteUserId', + 'setRemoteAuthenticatedUserId', + 'setRemoteStatusText', + 'setRemoteConnectionFailureKind', + 'setRemoteBusy', + 'setRemoteUrlInputVisible' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const appRootRuntimeStateGetters = [ + 'remoteUrl', 'userId', 'authenticatedUserId', 'statusText', 'connectionState', + 'connectionFailureKind', 'isBusy', 'showRemoteUrlInput', 'workspaceName', 'workspacePath', + 'workspaceBranch', 'workspaceKind', 'assistantId', 'desktopName', 'desktopId', 'activeSession', + 'messages', 'pendingMessages', 'activeTurnMessage', 'timelineItems', 'hasMoreMessages' +].filter((getter) => new RegExp(`^\\s{2}get\\s+${getter}\\s*\\(`, 'm').test(appRootRuntimeSource)); +const extractedOwnerForwards = [ + 'currentRoute', 'isRoute', 'isGeneralChatVisible', 'pushRoute', 'replaceRoute', 'popRoute', + 'handleConversationIntent', 'pasteRemoteUrl', 'scanRemoteUrl', 'handleDetectedRemoteUrl', + 'showRecentWorkspaces', 'showAssistants', 'refreshSessions', 'loadMoreSessions', 'setSessionFilter', + 'openAddConnection', 'selectRemoteCreateModel', 'loadRecentWorkspacesInBackground', + 'loadOlderMessages', 'removeSelectedImage', 'persistVisibleGeneralChatDraft', 'stopPolling', + 'nudgeChatPolling', 'pollActiveSession', 'startHeartbeat', 'stopHeartbeat', + 'checkConnectionHealth', 'resumeRemoteActivity' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); + +const expected = { + serviceToPages: [], + componentToViewmodel: [], + viewmodelToComponents: [], + v1Components: [], + positionalActionConstructors: [], + duplicatedConversationTraceFields: [], + extractedFilePreviewMethods: [], + extractedSettingsMethods: [], + extractedCloudAccountMethods: [], + extractedConversationMethods: [], + extractedRemoteConversationMethods: [], + extractedRemoteCreateMethods: [], + extractedGeneralConversationMethods: [], + extractedRemoteConnectionForwards: [], + appRootRuntimeStateGetters: [], + extractedOwnerForwards: [], + missingPresentationFiles: [] +}; + +function sameSet(actual, wanted) { + return actual.length === wanted.length && actual.every((item, index) => item === wanted[index]); +} + +const actual = { + serviceToPages, + componentToViewmodel, + viewmodelToComponents, + v1Components, + positionalActionConstructors, + duplicatedConversationTraceFields, + extractedFilePreviewMethods, + extractedSettingsMethods, + extractedCloudAccountMethods, + extractedConversationMethods, + extractedRemoteConversationMethods, + extractedRemoteCreateMethods, + extractedGeneralConversationMethods, + extractedRemoteConnectionForwards, + appRootRuntimeStateGetters, + extractedOwnerForwards, + missingPresentationFiles +}; +let failed = false; +for (const [name, wanted] of Object.entries(expected)) { + if (!sameSet(actual[name], wanted)) { + failed = true; + console.error(`${name} mismatch`); + console.error(`expected: ${JSON.stringify(wanted)}`); + console.error(`actual: ${JSON.stringify(actual[name])}`); + } +} +if (appRootRuntimeLines > 500) { + failed = true; + console.error(`AppRootRuntime line budget exceeded: expected <=500, actual=${appRootRuntimeLines}`); +} +if (appRootPresentationLines > 500) { + failed = true; + console.error(`AppRootPresentation line budget exceeded: expected <=500, actual=${appRootPresentationLines}`); +} +for (const [file, budget] of componentLineBudgets) { + const source = fs.readFileSync(path.join(pagesRoot, file), 'utf8'); + const lineCount = source.split(/\r?\n/).length - 1; + if (lineCount > budget) { + failed = true; + console.error(`${file} line budget exceeded: expected <=${budget}, actual=${lineCount}`); + } +} + +if (failed) { + process.exitCode = 1; +} else { + console.log('HarmonyOS architecture contracts are satisfied.'); +} From 92a620358163c48754bbd61ef0df93e6e1b46320 Mon Sep 17 00:00:00 2001 From: liao-zh <27865589+liao-zh@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:00:01 +0800 Subject: [PATCH 03/65] chore(i18n): unify copy wording in zh-CN and zh-TW locale --- src/web-ui/src/locales/zh-CN/common.json | 2 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 4 ++-- src/web-ui/src/locales/zh-TW/common.json | 2 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 4abc57dad..86f50ed91 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -727,7 +727,7 @@ "openBot": "打开机器人", "stateIdle": "就绪", "stateWaiting": "等待连接...", - "urlCopied": "已拷贝 URL", + "urlCopied": "已复制 URL", "copyUrl": "复制配对链接", "copyUrlFailed": "无法复制配对链接,请手动复制或检查剪贴板权限。", "weixinQrAlt": "微信登录二维码", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 8784928c3..44c90e133 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -899,8 +899,8 @@ "unknownTool": "未知工具" }, "transcriptExport": { - "copyFull": "拷贝完整过程", - "copyResult": "拷贝结果", + "copyFull": "复制完整过程", + "copyResult": "复制结果", "copyEmpty": "该对话没有可复制的内容", "exportEmpty": "该会话没有可导出的内容", "exportSuccess": "会话已导出:{{filePath}}", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index dbb952b16..5dd614047 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -727,7 +727,7 @@ "openBot": "開啟機器人", "stateIdle": "就緒", "stateWaiting": "等待連接...", - "urlCopied": "已拷貝 URL", + "urlCopied": "已複製 URL", "copyUrl": "複製配對連結", "copyUrlFailed": "無法複製配對連結,請手動複製或檢查剪貼簿權限。", "weixinQrAlt": "微信登入二維碼", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 767bde69b..7fd2d7049 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -899,8 +899,8 @@ "unknownTool": "未知工具" }, "transcriptExport": { - "copyFull": "拷貝完整過程", - "copyResult": "拷貝結果", + "copyFull": "複製完整過程", + "copyResult": "複製結果", "copyEmpty": "該對話沒有可複製的內容", "exportEmpty": "該工作階段沒有可匯出的內容", "exportSuccess": "工作階段已匯出:{{filePath}}", From a86e1916a4c86f0228d7e8a4eeed343390bcb71b Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 9 Aug 2026 12:31:15 +0800 Subject: [PATCH 04/65] refactor(external-sources): simplify integration controls Remove the parallel application connection and batch review surface, keeping the existing source policy and owner-specific permission controls as the single path. Reduce Web and TUI output, preserve remote and owner guards, and consume retired automatic defaults once without overwriting later user choices. --- docs/architecture/cli-product-line-design.md | 10 +- .../capability-runtime-integration-design.md | 4 +- ...nal-ai-app-connection-experience-design.md | 551 ---- .../external-ai-work-sources-design.md | 89 +- docs/architecture/product-architecture.md | 18 +- ...ernal-ai-app-connection-experience-plan.md | 637 ---- .../rules/source/public-api-rules.mjs | 48 +- scripts/core-boundaries/self-test.mjs | 9 - src/apps/cli/src/actions.rs | 26 +- src/apps/cli/src/agent/tui_client.rs | 49 +- src/apps/cli/src/modes/chat.rs | 5 +- .../cli/src/modes/chat/external_review.rs | 1477 +-------- .../cli/src/modes/chat/external_sources.rs | 489 +-- src/apps/cli/src/modes/chat/tests.rs | 70 +- .../peer_host/commands/external_sources.rs | 101 +- src/apps/cli/src/peer_host/commands/mod.rs | 15 +- src/apps/cli/src/tui_backend.rs | 73 +- src/apps/cli/src/ui/command_palette.rs | 1 - .../desktop/src/api/external_sources_api.rs | 206 +- .../src/api/remote_workspace_policy.rs | 31 - src/apps/desktop/src/lib.rs | 3 - .../src/tests/protocol_contracts.rs | 5 + .../assembly/core/src/external_sources.rs | 2744 +++-------------- .../src/external_source_control.rs | 633 ---- .../tests/external_source_contracts.rs | 402 +-- .../interfaces/app-server-client/src/lib.rs | 34 - .../interfaces/app-server-protocol/src/lib.rs | 10 + .../src/schemas/external_source.rs | 160 +- .../interfaces/app-server/src/management.rs | 30 - .../app-server/src/management/service.rs | 49 - .../src/server/handlers/external_source.rs | 27 - .../service-api/ExternalSourcesAPI.test.ts | 348 --- .../api/service-api/ExternalSourcesAPI.ts | 691 ----- .../ExternalSourcesConfig.appearance.ts | 8 +- .../components/ExternalSourcesConfig.scss | 280 +- .../components/ExternalSourcesConfig.test.tsx | 912 +----- .../components/ExternalSourcesConfig.tsx | 500 +-- .../ExternalAppsOverview.test.tsx | 331 +- .../external-sources/ExternalAppsOverview.tsx | 549 +--- .../ExternalCommandConflicts.tsx | 6 + .../ExternalSourceSection.tsx | 1 + .../external-sources/applicationModel.test.ts | 300 +- .../external-sources/applicationModel.ts | 327 +- .../applicationModel.v2.test.ts | 113 - .../components/external-sources/index.ts | 12 +- .../en-US/settings/external-sources.json | 135 +- .../zh-CN/settings/external-sources.json | 135 +- .../zh-TW/settings/external-sources.json | 135 +- 48 files changed, 1074 insertions(+), 11715 deletions(-) delete mode 100644 docs/architecture/extensions/external-ai-app-connection-experience-design.md delete mode 100644 docs/plans/external-ai-app-connection-experience-plan.md delete mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.v2.test.ts diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index f08fd8621..f3c800abe 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -10,8 +10,6 @@ - 公开 Agent SDK:[`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md) - 产品定制:[`product-customization-blueprint.md`](product-customization-blueprint.md) - 外部 AI 工作来源:[`extensions/external-ai-work-sources-design.md`](extensions/external-ai-work-sources-design.md) -- 外部 AI 应用连接体验:[`extensions/external-ai-app-connection-experience-design.md`](extensions/external-ai-app-connection-experience-design.md) -- 外部 AI 应用连接执行计划:[`../plans/external-ai-app-connection-experience-plan.md`](../plans/external-ai-app-connection-experience-plan.md) - OpenCode 兼容矩阵:[`extensions/opencode-extension-compatibility.md`](extensions/opencode-extension-compatibility.md) - 插件 Runtime:[`extensions/plugin-runtime-design.md`](extensions/plugin-runtime-design.md) - Detached Dispatch:[`detached-task-dispatch.md`](detached-task-dispatch.md) @@ -146,7 +144,7 @@ SHELL composer - `stream-json` stdout 每行是一个完整 Agent event。 - 日志与诊断进入 stderr 或日志文件。 - 默认拒绝需要人工确认的操作;只有显式调用级策略可以自动批准。 -- 目标连接体验交付后,只有 Agent Runtime 沿现有事件流返回与当前执行域、工作区作用域、根会话和根轮次完全匹配的依赖结果时,CLI 才投影类型化 `action-required`;当前实现尚未提供该结果。子代理必须通过现有父子关系事件证明仍属于根依赖链,无关待办或后台子代理不得改变退出结果。 +- 非交互入口不等待人工确认,也不从全局外部来源状态推断特殊任务结果。能力不可用时返回普通失败;能够可靠归属到 Tool、Agent 或 MCP owner 时,错误只给出对应管理入口。 - 取消、事件失步、失败完成和 Patch 失败不能报告成功。 ## 5. TUI 内部边界 @@ -185,10 +183,8 @@ CLI 通过 `DeliveryProfile::Cli` 消费经过校验的产品 Runtime parts。 CLI 只消费 typed summary 与 typed action: -> **实现状态:部分交付。** 交互式 TUI 已通过 Host 返回的 V2 快照提供应用级状态、连接、断开、暂不使用和分页批量确认;Embedded 连接旧 Host 时回退既有 V1 只读状态,未接线的 Shared Runtime 明确不支持且绝不回退到控制进程本地执行。任务相关 `action-required` 与非交互 CLI 结果仍未交付。 - -- `/extensions` 是应用级摘要、首次连接和状态恢复入口;`/extensions review` 提供与 GUI 等价的单页批量确认。 -- `/tools`、`/agent`、`/mcp` 和 `/hooks` 保留能力专项或高级管理职责,不复制应用级连接流程。 +- `/extensions` 只提供外部应用/来源的简短状态、启停和刷新,不拥有审批、冲突或批量决策。 +- `/tools`、`/agent`、`/mcp` 和 `/hooks` 是对应能力的直接管理入口;需要用户允许时由真实 owner 在该入口处理,不再增加跨能力复审流程。 - 静态发现不等于代码执行或服务健康。 - 配置导入不授予插件执行权限。 - ACP、MCP import、Hook import、可执行插件和 TUI contribution 使用独立状态与生命周期。 diff --git a/docs/architecture/extensions/capability-runtime-integration-design.md b/docs/architecture/extensions/capability-runtime-integration-design.md index 5b5bde622..f02b9484b 100644 --- a/docs/architecture/extensions/capability-runtime-integration-design.md +++ b/docs/architecture/extensions/capability-runtime-integration-design.md @@ -435,8 +435,8 @@ OpenCode,多语言协议与发布一致性参考 Copilot SDK。最终结构和 ## 10. 产品体验要求 -1. **不阻塞正常工作**:发现、准备、兼容检查和无关待确认项在后台进行;只有当前操作真正依赖待确认能力时返回 - 类型化 `action-required`。 +1. **不阻塞正常工作**:发现、准备、兼容检查和无关待确认项在后台进行;当前操作真正依赖不可用能力时,由该能力 owner + 返回普通失败并指向对应的权限或配置入口,不增加跨能力任务结果类型。 2. **能力状态可解释**:设置页、CLI 和 SDK 能看到来源范围、执行位置、外部宿主、native/degraded 状态、最终 Provider、权限 上限、最近错误和恢复动作;默认界面只显示需处理项和聚合摘要。 3. **不重复打扰**:同一来源/能力/候选内容摘要只询问一次;内部 `prepare/ready/activate` 阶段不逐层重复审批。 diff --git a/docs/architecture/extensions/external-ai-app-connection-experience-design.md b/docs/architecture/extensions/external-ai-app-connection-experience-design.md deleted file mode 100644 index 3d55dfb88..000000000 --- a/docs/architecture/extensions/external-ai-app-connection-experience-design.md +++ /dev/null @@ -1,551 +0,0 @@ -# 外部 AI 应用连接与管理详细设计 - -本文定义“外部 AI 应用”在 Desktop Settings、交互式 TUI 和非交互 CLI 中的应用级连接与管理体验。稳定架构、归属模块和运行视图见[外部 AI 工作内容架构](external-ai-work-sources-design.md),实施顺序见[外部 AI 应用连接体验执行计划](../../plans/external-ai-app-connection-experience-plan.md)。 - -本文只描述交互、应用级读模型、动作语义和宿主投影,不重定义生态解析、能力归属、执行权限或插件运行时。 - -> **实现状态:部分交付。** 当前分支保留严格 V1 兼容路径,并已交付独立 V2 应用快照、作用域化连接偏好与迁移、分页批量确认、Desktop/Peer 投影,以及 Desktop Settings 和交互式 TUI 消费。App Server 只在真实注入 management owner 的宿主中暴露这些方法;Shared Runtime 与通用 Server 不伪装支持。任务相关 `action-required`、非交互 CLI 结果和 `HookManagementSnapshot` 仍是后续工作。Hook 管理继续沿用独立 owner 和现有安全审核契约,其产品入口与展示规则见第 5.5、7.1 和 8.3 节。 - -## 1. 问题与设计目标 - -当前 Settings 页面把接入策略、物理来源、Tool、Subagent、MCP、冲突、诊断和 Safe Mode 平铺在同一页面。用户必须理解内部能力分类,才能完成“使用另一个 AI 应用中的能力”这一主任务。 - -目标是: - -1. 以外部应用而不是能力类型作为首次连接和日常管理入口。 -2. 明确区分发现、连接和加载,避免“发现即运行”。 -3. 对低风险声明式内容采用低摩擦默认路径,对可执行或权限扩大的内容集中确认。 -4. 给连接动作明确完成反馈,说明已启用、待确认和受限内容。 -5. 适配 Settings 约 600px 的正文宽度,采用纵向单列和渐进披露。 -6. 提示低侵入、一次性、状态驱动;用户已决定后不重复打扰。 -7. GUI 与 TUI 共享产品语义、状态、默认策略和决策结果,不共享布局与渲染实现。 - -## 2. 范围与非目标 - -本设计覆盖: - -- Desktop Web UI 的应用首页、详情、批量确认和高级设置; -- TUI `/extensions` 的应用摘要、连接和批量确认; -- 非交互 CLI 的任务相关 `action-required`; -- Peer Host / Server 对共享应用级读模型和类型化动作的投影; -- 默认连接产品事实、提示去重和跨宿主决策一致性。 - -本设计不包含: - -- 外部聊天历史或项目迁移; -- 将持续来源复制成 BitFun 原生配置; -- 自动连接或加载所有检测到的应用; -- 自动运行所有 Tool、Subagent、MCP、Hook、进程或网络能力; -- 改变生态配置解析、能力归属、权限归属或安全上限; -- GUI/TUI 共享布局、组件、主题 key、快捷键或渲染 schema; -- 无法可靠实现的全局撤销; -- 扩展 OpenCode legacy managed-package 路径为目标运行时模型。 - -“导入”只用于真正复制或迁移数据的独立能力。持续兼容来源统一使用“发现、连接、加载、断开连接”。 - -### 2.1 核心术语 - -正文优先使用中文,协议字段保留代码名: - -| 术语 | 含义 | -|---|---| -| 执行域(`execution_domain_id`) | 外部事实被读取、能力被加载的真实宿主边界 | -| 工作区作用域(`workspace_scope_id`) | 宿主为当前工作区计算的不透明策略键,只在所属执行域内有效 | -| 用户默认(`user_default`) | 同一执行域内,没有工作区覆盖时使用的缺省决定 | -| 工作区覆盖(`workspace_override`) | 只影响当前工作区、且优先于用户默认的决定 | -| 发现代次(`generation`) | 一次不可变发现结果的版本,用于拒绝过期操作 | -| 偏好版本(`preference_revision`) | 用户决定文档的版本,用于并发保护 | - -## 3. 产品状态模型 - -### 3.1 发现 - -发现是只读扫描:识别外部应用及其用户级、项目级或工作区级候选,生成脱敏摘要、支持范围和风险事实。 - -发现不得注册运行时能力、启动外部进程、建立网络连接、读取凭据值、改写配置,或把候选加入模型可调用集合。 - -### 3.2 连接 - -连接表示用户或产品默认策略允许 BitFun 在明确的执行域和策略作用域内持续读取并同步某个生态。连接是应用级、作用域相关的状态,不等同于允许其全部内容运行,也不能从一个工作区或宿主外溢到另一个执行域。 - -连接结果必须包含: - -- 已连接的应用; -- 已自动启用的低风险内容; -- 等待确认的类别和数量; -- 被安全上限阻止或暂不可用的内容; -- 唯一下一步主操作。 - -### 3.3 加载 - -加载表示将策略允许或用户确认的具体能力注册到真实归属模块。只有同时满足以下条件的内容可以加载: - -- 低风险声明式内容已被共享策略允许自动应用,或用户已确认该能力; -- 未超过产品、组织、宿主能力、Safe Mode 和安全上限; -- 发现代次、偏好版本、决策键与行为版本仍有效; -- 对应能力归属模块已完成自身校验、准备和注册。 - -下图是目标产品流,不代表当前 V1 已具备这些能力: - -```mermaid -flowchart LR - A["只读发现
生成应用摘要"] --> B["作用域连接决定
默认仅当前工作区"] - B --> C["加载低风险内容
归属模块最终校验"] - B --> D["待确认摘要"] - D --> E["有界分页读取
每页最多 128 项"] - E --> F["用户确认"] - F --> C - C --> G["更新应用结果摘要"] - C -. "当前任务实际受阻" .-> H["只提示当前会话与轮次"] -``` - -### 3.4 面向用户的应用级状态 - -首页只展示五种应用级摘要: - -| 状态 | 含义 | 默认主操作 | -|---|---|---| -| 已连接 | 连接有效,当前没有必须处理的应用级事项 | 查看 | -| 发现可用配置 | 已发现候选,但尚未连接 | 连接 | -| 未发现配置 | 支持该应用,但当前执行域没有配置 | 无强调操作 | -| 需要处理 | 存在待确认、权限扩大、阻断性冲突或应用级恢复事项 | 检查 | -| 暂时不可用 | 连接、同步或宿主状态失败,且存在恢复路径 | 重试或查看原因 | - -这些是从底层发现、期望连接、确认、运行、支持、健康和冲突事实派生的持久产品摘要,不替代架构文档定义的正交生命周期。优先级为:`需要处理 > 暂时不可用 > 已连接 > 发现可用配置 > 未发现配置`;Safe Mode 作为全局显著状态单独展示,不被该优先级隐藏。当前轮次的任务依赖作为短期、作用域化导航上下文单独呈现,不写回应用状态。 - -“已启用”只描述能力结果,不替代“已连接”。应用可以已连接,同时仍有部分能力等待确认或被限制。 - -## 4. 默认连接与推荐集合 - -### 4.1 默认连接产品事实 - -默认连接由 Product Assembly 提供的生态能力事实决定,不能在 React、TUI 或协议 adapter 中按 `ecosystemId` 硬编码。 - -首期策略: - -- OpenCode:允许默认连接;低风险声明式能力按策略自动加载;Tool、Subagent、MCP、进程、网络、环境变量或权限扩大仍进入确认。 -- Codex、Claude Code:默认只发现,不连接、不加载;用户可主动连接。 - -读模型同时给出默认值和原因,例如适配成熟度、支持范围、产品策略或当前宿主限制。明确的“断开连接”或“暂不使用”优先于后续默认连接,不能被自动发现覆盖。 - -### 4.2 推荐集合 - -批量确认默认选中共享控制面计算的推荐集合,高风险项默认不选。推荐计算至少考虑: - -- 能力类别和行为风险; -- 本地进程、网络、环境变量、文件范围和权限扩大; -- 来源、作用域与适配支持范围; -- 宿主能力、Safe Mode、产品/组织安全上限; -- 冲突、诊断和兼容状态; -- 用户既有决策及其绑定的行为版本。 - -宿主只能展示推荐、允许用户在安全上限内调整并提交选择,不能自行提高推荐等级或放宽上限。 - -### 4.3 作用域与旧偏好迁移 - -连接决定沿用现有集成策略的两级语义,而不是建立一个跨工作区的全局布尔值: - -- `user_default` 绑定 `execution_domain_id + application_id`,不带工作区作用域,只作为同一执行域内工作区的缺省值; -- `workspace_override` 绑定 `execution_domain_id + workspace_scope_id + application_id`,优先于 user default; -- `workspace_scope_id` 直接复用 `assembly/core` 现有 `workspace_policy_key` 生成的不透明键:`workspace:` 加规范化工作区 SHA-256 的前 16 字节十六进制。它由事实所在宿主计算并随快照返回,控制端只原样回传;它不是路径、没有反查索引,也不建立新的全局工作区注册表。Peer/Remote 宿主必须在自身执行域计算,控制端不得用本机目录代算; -- 现有 `workspace_overrides` 已以同一不透明键为键,迁移可以原样枚举,不需要也不得反查绝对路径。宿主身份或执行域改变后旧键不能跨域复用;显式无工作区使用 `none`,不是任意工作区的通配符; -- 偏好版本、提示键和确认计划都在同一作用域内解释,不能跨作用域去重或重放。 - -现有 `ExternalSourcesConfig` 已保存 integration policy、来源抑制、Tool/Subagent/MCP 审批和冲突决定,但没有应用连接字段。`integration_policy.enabled=false` 同时表示结构体默认值和用户显式关闭,而且现有 MCP revision-key 初始化可能把默认对象自动写成文件;因此不能再用“有文件/无文件”或 `false` 单独还原用户意图。升级必须先读取原始存储状态,再进入会物化默认文件的 helper,并在现有原子读改写路径中执行可重入迁移: - -1. `WorkspaceExternalSourceService` 的启动迁移关口必须成为偏好存储的第一次访问:它先读取原始文件存在性和 `schema`,完成或保留迁移后,才允许发现、MCP 版本键初始化或 V2 接口继续。只有确认从未存在过偏好文件的新安装才写入 `config_origin=fresh_v2`,保持“无用户决定”并应用新的产品默认。已有旧文件或不兼容策略重置都不能重新归类为 fresh V2。 -2. 迁移关口在内存中一次计算所有旧用户默认和 `workspace_overrides` 的连接决定;每项都按 `(execution_domain_id, application_id, workspace_scope_id?)` 写入真实连接状态与 `decision_origin`,无法归属的项写为 `needs_review`。`connection_schema_migration_version` 只表示整份文档已完成一次原子转换,不引入逐作用域的迁移生命周期。 -3. 任何旧文件中的 `integration_policy.enabled=false` 都保守迁移为该作用域的显式未连接,`decision_origin=legacy_safety`;这包括由旧版自动生成、无法与用户显式关闭区分的默认文件。该规则优先于“已有有效使用”判断,保证升级不意外启用能力;可能要求从未手动关闭的旧用户重新连接一次,并应在迁移说明中明确,而不能用 OpenCode 新默认覆盖。 -4. 仅当旧策略的 `integration_policy.enabled=true`,且该作用域已有效使用某生态——至少一项能力的实际访问级别为 `ask_before_use`/`auto`,或存在可归属到该生态的有效审批、冲突决定或活动路由——才迁移为已连接,避免升级静默撤下现有 Claude Code/Codex/OpenCode 能力。现有 `workspace_overrides` 直接按不透明 `workspace_scope_id` 逐项迁移。 -5. 审批、拒绝和冲突记录不因连接迁移而删除;重新连接时仍需决策键与行为版本匹配,权限扩大继续重新确认。无法可靠归属到应用、执行域或某一作用域的旧记录写为连接状态 `needs_review`,该作用域继续使用 V1 路径,不得猜测连接、静默停用或用新默认接管。 -6. 若读取到未知未来 `schemaMajor`,必须沿用现有不兼容策略的安全拒绝语义:不迁移、不应用默认、不写任何 V2 决定,也不触发偏好文件重写,逐字节保留包含不透明策略的原文件。用户执行既有“备份并重置”时,在同一原子更新中保存原策略、写入 `config_origin=incompatible_reset` 和显式未连接决定;该来源永不应用默认连接,只有用户随后显式连接才能启用能力。 -7. 全部作用域决定、`connection_schema_migration_version` 和既有审批/冲突事实必须在同一次锁内原子替换中提交。成功时不存在“部分迁移”;失败则保持原文件和完整 V1 运行路径,重启后重新计算并重试整次转换。 - -Instruction、Skill、Hook 和显式复制成 BitFun 原生配置的内容继续由各自归属模块决定。只有归属模块已提供来源限定的激活/撤下端口时,应用连接才能协调其持续外部来源;否则应用摘要必须标记 `managed_separately` 或部分支持,断开连接不得虚假宣称已卸载。已经复制的原生 Hook/MCP 等快照不随外部应用断开而删除。 - -## 5. Desktop Settings 信息架构 - -### 5.1 首页 - -首页沿用现有 `ConfigPageLayout` 的 760px 正文最大宽度。接入设置不能藏在详情或“高级设置”深层,默认页面只保留: - -1. 一个应用级总开关,控制是否使用外部 AI 应用能力; -2. 一个推荐模式入口,默认由产品安全策略自动完成发现、连接和低风险能力加载; -3. “需要确认”入口,仅在确有少量不能安全自动决定的事项时显示数量; -4. 一个可展开的应用/能力树,供用户查看结果或覆盖单项决定; -5. Safe Mode,仅在生效或当前宿主可操作时显著展示。 - -首页不平铺 Tool、Subagent、MCP、Hook、来源路径、冲突、完整诊断、scope 和兼容参数。应用树默认折叠,只在用户希望检查单项状态时展开。 - -默认路径的目标不是让用户逐项配置,而是由产品完成大多数决定:只读发现自动执行;成熟适配中安全上限内的低风险声明式内容按推荐策略自动连接和加载;已有等价决定静默复用;普通更新静默同步。只有可执行内容首次信任、实质权限扩大、无法自动选择的真实冲突,或宿主安全策略要求时才进入“需要确认”。 - -用户处理全部待确认事项应在一个页面或弹层内完成:默认应用共享推荐集合,提供“使用推荐设置”和“暂不启用”少量主操作;可展开树只用于查看和调整例外项。正常流程不要求用户理解能力分类、来源文件、作用域或诊断码。 - -应用行与能力节点用于解释自动化结果,而不是要求用户逐项决策。顶层只显示应用名、整体状态和简短结果;展开后才显示能力类别与单项状态。没有问题的应用不展示操作按钮,连接、断开或单项覆盖通过同一树内的上下文操作完成。 - -### 5.2 应用树与逐级披露 - -默认页面不再要求用户进入独立应用详情才能接入或查看结果。应用树按外部应用分组;每个折叠行只包含展开箭头、应用名称、已启用能力图标和应用总开关: - -- 打开应用总开关等价于“使用推荐设置”,系统自动应用安全且无歧义的决定,不启动配置向导; -- 关闭应用总开关撤下该应用贡献的运行能力,但不修改外部配置、不删除已保存决定,也不影响其他应用; -- 只显示已启用能力类型的紧凑图标,未启用类型不占位;图标全名、数量和状态原因通过 tooltip 或无障碍标签提供; -- 应用或能力下存在待确认项时只显示圆点/计数,不重复放置确认按钮或说明段落; -- 展开应用后显示能力类型及类型开关,再展开能力类型才显示单项覆盖;单项明细不是正常使用的前置步骤。 - -正常状态下不显示“管理连接”、恢复或确认文案。低频作用域覆盖、来源路径、完整诊断、兼容说明和恢复动作进入高级设置。Safe Mode 生效时仍必须显著显示,不能因折叠隐藏。 - -### 5.3 单一确认入口 - -Tool、Subagent、MCP、Hook 和无法自动决定的真实冲突进入同一个确认页面或弹层,不使用连续弹窗,也不要求按应用重复提交。顶部仅在存在真实待确认项时显示一个带数量的入口;应用树只负责定位相关应用和能力。 - -确认页默认只显示总数、简短风险摘要和共享推荐集合,并提供“使用推荐设置”和“暂不启用”两个主要决定。用户展开分类或单项后,才展示名称、来源、路径、命令、环境变量名、网络目标、冲突和行为变化。敏感值、完整 prompt、完整 URL query 和未经脱敏的绝对路径不进入公共快照。 - -系统应把确认项压缩到最少:只读发现、低风险声明式能力、行为等价更新、已有有效决定和无歧义优先级自动完成。首次可执行内容信任、实质权限扩大、无法安全自动消解的冲突及组织策略要求才需要确认。待确认项在决定前不运行,但同应用其他安全能力继续生效。 - -提交不要求客户端读取全部分页:`review_id` 绑定同一不可变确认计划,`selection_baseline` 只能是共享推荐集合或空集合,`selection_overrides` 只携带与基线不同的稳定项目引用和选择结果。服务端从同代权威计划还原完整选择,依次应用基线和改动项,再校验作用域、偏好版本、发现代次、决策键、行为版本、安全上限和最大选择数。计划过期或引用不属于该计划时整批拒绝,不能把不同页面或不同代次拼接。 - -批量语义: - -- stale revision、无效 generation 或宿主能力整体不兼容时,整个请求不应用; -- owner 允许逐项业务拒绝时,响应返回逐项结果;宿主只把成功项标为已启用; -- “整个请求不应用”只保证分派前的 identity、revision 和 generation 预检;开始分派后若 owner 状态并发变化,可以同时返回已应用项和类型化 stale/failed 项,不承诺跨 owner 回滚; -- 未知结果不能假定成功; -- 失败项保留可行动原因与恢复动作。 - -### 5.4 高级设置 - -以下内容后置到可展开树或高级设置:全局/项目 scope、生态与能力覆盖、物理来源、完整诊断、配置位置、Safe Mode 恢复和兼容说明。高级设置不是正常接入的前置入口,也不能重复承载应用总开关或待确认主操作。 - -### 5.5 扩展能力与 Hook - -External AI Apps 是 GUI 中查看外部应用及其能力的唯一 Settings 一级入口。Hook 与 Command、Skill、Agent、Tool、MCP 等并列,是扩展能力类型之一;不能把 Hook 提升为与外部应用并列的产品域,也不能为了统一界面创建承载所有能力载荷的通用 Extension 对象。 - -应用树中的能力节点把 Hook 与其他类型并列展示。折叠应用行只显示已启用的 Hook 图标;展开后显示已启用数量和待确认标记,再展开 Hook 节点才列出原生、已导入和可审核单项。Hook 摘要至少区分已启用、待确认、需要更新、部分兼容和异常;技术性的“已发现/可导入/已导入”只在单项详情中表达,不作为顶层用户状态。 - -Hook 节点同时覆盖: - -1. BitFun 用户级和项目级原生 Hook; -2. 已导入并由 BitFun 管理的外部 Hook; -3. Claude Code、Codex 等外部应用中可审核导入的 Hook; -4. Hook 总开关、项目 Hook 开关、配置位置和兼容说明,这些内容作为类型专属高级控制呈现。 - -该节点优先用图标、开关和计数回答当前哪些 Hook 会运行、来自哪里以及是否需要确认;解释文字进入 tooltip、无障碍标签或按需详情。完整命令、依赖、matcher 和诊断只随单项展开。Settings 不再单列 Agent Hooks 一级入口;既有深链可兼容导航到 External AI Apps 并展开对应 Hook 节点,但不能继续形成第二套管理页面。 - -Hook 数据仍由 `native_hooks`、`external_hooks` catalog 和 `external_hook_import` 各自拥有。External AI Apps 只消费应用与能力摘要,不复制可执行载荷、不修改外部来源文件,也不取代 Hook 的精确计划审核、revision fencing、行为版本和运行开关。 - -加载遵循渐进披露:默认页只读取轻量摘要;用户展开应用、能力节点,或已有 Hook 相关真实待办时才读取对应详情。不能为了默认页精确计数而预加载命令和依赖;摘要不足时使用图标状态而不是触发无界扫描。 - -### 5.6 当前 V1 修复切片 - -在组合 `HookManagementSnapshot` 尚未交付时,Desktop 仍必须保证既有 Hook 管理能力可达,但不能为此恢复第二个 Settings 一级入口或复制一套 Hook 数据 owner。当前 V1 修复切片采用以下过渡边界: - -1. `External AI Apps` 保持唯一一级入口;页面内提供一个按需展开的 Hook 专属区域,复用现有 `app.hooks` 配置、`external_hooks` catalog 和 `external_hook_import` 操作。区域未展开时不读取 Hook 详情;旧 `hooks` 深链进入本页并自动展开、聚焦该区域。 -2. Hook 区域只做现有 owner 的组合呈现,不创建新的后端协议、不复制可执行载荷,也不把现有 Hook 操作改接到 external-source policy。后续组合快照交付时替换读模型,不改变现有写操作的 owner。 -3. 应用总开关从 `custom` 关闭时只把当前 mode 设为 `disabled`,不清除当前作用域的 capability overrides;重新打开时,存在保留 override 的应用恢复为 `custom`,否则进入 `recommended`。显式重置会清除 override,因此仍返回推荐模式。 -4. 应用树的能力状态展示生效权限,而不是连接计划中的推荐权限。`auto`、`ask_before_use`、`discover_only` 和 `disabled` 必须有不同且准确的用户文案;推荐值只用于产生默认决定,不能冒充当前状态。 -5. 首次加载且没有可展示快照时,页面显示可访问的错误提示和带文字的重试操作,并暂不展示依赖快照的应用树与高级设置。已有 last-valid 快照时继续展示该快照,同时标记降级状态,不能因刷新失败把现有内容替换为空白页。 -6. 新增或调整的 appearance part 必须在同一 DOM 节点声明所属 component,并与 appearance 注册表一一对应;不能通过放宽审计或保留不存在的 part 让检查通过。 - -该切片必须用生产路径组件测试覆盖旧深链到 Hook 区域、Hook owner API 可达、`custom -> disabled -> custom` 权威快照往返、四种生效权限文案、无快照错误恢复和 last-valid 降级显示,并通过 appearance contract audit。它不实现完整 Hook 摘要计数、跨宿主通知决定或新的共享连接协议。 - -## 6. 提示、去重和恢复 - -### 6.1 首次发现 - -不使用启动弹窗。允许的入口是: - -- 聊天区一次性非阻塞轻提示; -- Settings 导航低侵入状态; -- Settings 内应用摘要。 - -文案只说明“发现了可连接的应用”,不能暗示能力已经加载。 - -### 6.2 持久化去重 - -提示与用户决定由共享持久化事实驱动,不能只保存在某个 GUI/TUI 进程。去重键至少包含: - -- execution domain ID; -- `user_default` 或 `workspace_override`;workspace override 还包含 Host 返回的 `workspace_scope_id`; -- application / ecosystem ID; -- 内容或行为版本; -- 风险摘要版本; -- 用户决定状态。 - -用户关闭、完成确认、断开连接或选择“暂不使用”后,同一作用域、同一有效版本不再主动提示。仅数量变化但行为和风险未扩大时,只更新 Settings 摘要。用户级决定可以作为同一执行域的缺省值,workspace override 只影响对应 `workspace_scope_id`;任何决定都不能跨执行域传播。 - -### 6.3 再次主动提示 - -仅允许: - -1. 当前任务真正依赖待确认能力并因此受阻或降级; -2. 已确认内容发生实质权限扩大,需要重新确认。 - -权限扩大包括新增进程执行、网络访问、环境变量读取、更宽文件范围、工具集合扩大、模型或 Subagent 行为变化。行为等价刷新、普通路径变化和未连接应用更新不构成主动提示理由。 - -“当前任务受影响”不是持久化应用快照字段,也不参与应用级提示去重。能力归属模块在实际解析或调用依赖时,如果被连接策略或批量确认阻止,就返回类型化依赖事实;Agent Runtime 负责把它关联到根轮次并沿现有 Agent 事件流发布。现有 `session_id + turn_id` 已唯一标识根任务,不再新增一套任务身份。一个轮次的待确认能力不能改变另一个并发轮次的状态或退出结果。 - -子代理结果不得只凭“来自当前会话树”就使根任务失败。Runtime 使用现有 `SubagentSessionLinked` 的父 session、父 turn 和父 tool-call 关系追溯来源:只有根 turn 仍在等待该子代理调用时,子代理的阻断事实才聚合到根任务;无关、后台或已经脱离等待链的子代理结果保留在其来源 turn。事件在对应根任务结束事件之前发出,CLI/Host 只消费与当前根 session、turn 完全匹配的结果。 - -### 6.4 错误与恢复 - -必须区分发现失败、连接失败、同步暂时失败但沿用上一版本、stale revision、Host/Remote 不支持、Safe Mode 或 safety ceiling 阻止。 - -读模型提供类型化恢复动作,例如刷新、重试、重新连接、重新审阅、解决冲突、安装运行时、升级/重连 Host 或退出 Safe Mode。宿主不得解析错误文本决定控制流。 - -## 7. TUI 与非交互 CLI - -### 7.1 TUI - -`/extensions` 是应用级摘要和首次连接主入口,展示与 Settings 首页等价的状态、默认策略、数量和主操作。 - -`/extensions review` 提供与 GUI 等价的批量确认语义:共享推荐集合、高风险默认不选、可展开技术详情并调整。能力管理沿用竞品与 BitFun 已建立的直达命令:`/hooks`、`/tools`、`/agent` 和 `/mcp` 都是完整专项入口,不要求用户先进入 `/extensions`,也不新增 `/extensions hooks` 一类层级。GUI 的应用/能力导航不能被强加为 TUI 命令心智。 - -`/hooks` 同时展示 BitFun 用户级和项目级 Hook、已导入的外部 Hook,以及 Claude Code、Codex 等应用中可审核的 Hook 来源,并提供现有审核、导入、更新、启停和移除操作。`/hooks_external` 与 `/hooks-external` 仅保留解析兼容,不进入推荐帮助和补全。TUI 与 GUI 使用同一后端派生状态和安全操作,但各自保留适合表面的布局。 - -首次发现只显示一次非阻塞摘要;无关待办不阻塞聊天输入。 - -### 7.2 非交互 CLI - -非交互命令不等待确认输入。只有当前操作真正依赖待确认能力时返回类型化 `action-required`,包含: - -- 受影响应用和能力摘要; -- 风险原因; -- 可执行的后续动作或交互入口; -- 当前操作是否可降级继续。 - -与当前操作无关的待确认能力不能导致命令失败。 - -## 8. 应用级读模型 - -产品级协调 owner 应通过独立 V2 协议提供宿主可直接投影的版本化应用级读模型: - -```text -ExternalApplicationSnapshotV2 - schema_version = 2 - execution_domain_id - workspace_scope_id? # 复用宿主的 workspace_policy_key;none 表示无工作区,不是通配符 - effective_connection_scope - refresh_generation - preference_revision - safe_mode - host_capabilities - applications[] - application_id / ecosystem_id / display_name - discovery / connection / health - effective_status / primary_action - default_connection_policy + reason - enabled / pending_review / blocked / conflict counts - risk_summary - notice_key / user_decision - recovery_actions - review_summary - review_id / total_count / category_counts / max_selection_count - risk_summary / recommendation_summary / safety_ceiling -``` - -应用级对象是对同一生态多个物理来源和能力事实的聚合。它不携带可执行载荷,不取代现有目录与能力专属 DTO。首页快照只携带批量确认摘要,不能内嵌完整项目列表;否则每次轮询都会重复序列化与首页无关的大量候选。 - -用户进入批量确认页后,客户端再调用有界只读接口取得稳定引用: - -```text -ExternalApplicationReviewPageV2 - schema_version = 2 - execution_domain_id / workspace_scope_id? / target_scope - review_id / preference_revision / expected_generations - cursor / next_cursor / total_count - items[] # 每页最多 128,只含 item reference、显示摘要、推荐与安全上限 -``` - -首次打开确认页时,请求不带 cursor 和 expected generations;若后台发现已在首页快照后完成,Host 可以返回当前只读确认计划,并以响应中的 `review_id` 和 generations 作为后续翻页与提交的唯一基准。偏好版本、执行域、工作区和目标作用域仍必须完全匹配。首次响应之后,分页游标严格绑定作用域、`review_id`、偏好版本和发现代次;任一事实变化都返回过期并重新读取,不能把旧页与新页拼接。详细页通过稳定项目引用关联现有 Tool、Subagent、MCP 和冲突投影;总量继续服从现有归属模块上限,完整提示词、命令正文、凭据和可执行载荷不进入分页响应。 - -状态和主操作由共享归属模块派生;React、TUI、Peer 和 Server 不重复实现优先级规则。 - -### 8.3 Hook 管理读模型与通知决定 - -Hook owner 应提供一个面向产品表面的版本化组合读模型,供 External AI Apps 和 TUI `/hooks` 使用。它组合原生 Hook 概览、外部 Hook catalog 与导入摘要,但不成为新的数据 owner: - -```text -HookManagementSnapshot - schema_version / revision - native - enabled / project_hooks_enabled - configured_count / active_count / issue_count - applications[] - ecosystem_id / display_name - discovered_count / importable_count - imported_count / enabled_count - update_count / unsupported_count / issue_count - attention_state - imports[] - existing import summaries - notice? - notice_key / attention_reason / acknowledged -``` - -摘要不得携带完整命令、环境变量值、依赖文件正文、matcher 正文或其他执行载荷。能力专属操作继续调用现有 Hook API;写操作成功后返回或重新读取权威快照,客户端不能长期维护乐观派生状态。现有 `ExternalHookImportSnapshotV1` 能直接表达的字段应复用,不能复制一套同构导入协议。 - -首次发现圆点的已查看事实必须由事实所在 Host 持久化,不能只写 React `localStorage`。最小决定键包含 execution domain、可选 workspace scope、ecosystem、notice kind 和行为或摘要版本。`acknowledged` 只消除低侵入提示,不代表信任、导入或允许运行;安全决定仍由现有计划指纹、revision、行为版本和运行开关控制。相同行为版本跨重启不重复提示,只有新审核、实质权限扩大、已激活 Hook 失效或真实冲突才能产生新 notice。 - -冲突由能力 owner 在默认状态确实不能共存、用户正在启用冲突项,或外部变化使既有决定失效时产生。仅发现多个候选或数量变化不构成冲突,也不触发打断式确认。 - -任务依赖通过执行路径单独返回,不进入可轮询、可持久化的应用快照: - -```text -AgenticEvent::ExternalDependencyActionRequired - schema_version = 2 - execution_domain_id / workspace_scope_id? - session_id / turn_id # 根任务身份 - origin_session_id / origin_turn_id / origin_tool_call_id? - dependency_refs[] / risk_summary / can_degrade - recovery_actions -``` - -该契约归 `bitfun-events` 所有,而不是应用快照归属模块或 CLI。`AgentSubmissionResult` 仍只表示轮次已被接收;Runtime 在真实能力解析路径产生事件,现有 App Server `agent/event` 与 Shared Runtime IPC `RuntimeIpcEvent::Agent` 承载 `AgenticEventEnvelope`。新增事件前必须补齐 App Server 协议/客户端、Shared IPC 协议版本兼容处理和 Embedded/Shared 等价测试。 - -该事件与外部来源 V1/V2 接口是两个版本边界,不能因为应用快照是 V2,就假设旧 App Server 客户端能解析新的 `AgenticEvent` 类型。实现必须提升 App Server 协议版本,并按每条连接协商出的版本过滤新事件;旧协议连接不得收到未知类型。若无法可靠过滤,则提升最低协议版本并在初始化阶段安全拒绝旧客户端。Shared IPC 同步提升其严格 `PROTOCOL_VERSION`。新客户端连接旧宿主时必须明确返回“任务依赖结果不支持”,不能从结束文本推断。只有根会话和根轮次完全匹配的任务可以据此返回 `action-required`;Settings 可把它作为短期导航上下文读取,但不能合并成所有任务共享的应用状态。 - -### 8.1 V1/V2 协议边界与协商 - -现有 `ExternalSourceControlSnapshotV1`、`ExternalSourceControlActionV1`、`ExternalSourceRecoveryActionV1` 和 V1 `hostCapabilities` 保持字段与闭合枚举不变。应用级快照、连接动作、批量确认、`upgrade-host` 语义以及新增能力位不得追加到 V1 对象。 - -`get_external_application_snapshot_v2` 不提交用户决定或运行能力写动作,直接承担版本探测,不再增加单独的版本信息接口。首次激活对应 owner 时允许执行可重入偏好迁移并启动既有后台发现;当前 V2 偏好读取不得重复写回。确认分页只读取已激活 owner 的不可变结果,不能冷启动服务或发现: - -- 新宿主返回严格的 V2 快照和 `host_capabilities`;客户端校验成功后,才可读取分页确认项或发送 V2 写操作; -- 旧宿主对 V2 快照返回传输层 method-not-found 时,客户端回退显示 V1 来源/能力管理并禁用 V2 写操作; -- “升级宿主”由新客户端根据 method-not-found 本地投影,不能向旧宿主发送未知 V2 动作,也不能要求旧宿主返回 V1 不认识的恢复类型; -- 旧客户端只调用原 V1 接口,因此新宿主必须继续生成严格 V1 响应;V1/V2 快照不得拼接成混合数据结构; -- 数据结构不匹配、宿主身份变化或重连后,所有未完成 V2 写操作和分页游标失效并重新读取快照。 - -兼容测试必须覆盖旧客户端 → 新宿主、新客户端 → 旧宿主、V2 同代成功、未知数据结构/枚举安全拒绝,以及重连后旧响应不能覆盖新执行域或工作区作用域。 - -### 8.2 性能与演进约束 - -- 应用快照和确认分页必须从当前不可变发现结果派生;首次快照可触发既有 owner 的迁移和后台发现,确认分页不得重新扫描文件、冷启动 owner、启动外部进程或持有偏好写锁。 -- 首页只返回摘要,确认页每页最多 128 项。完整候选总量继续服从各归属模块已有上限,不建立第二套无界缓存。 -- 共享缓存只允许按执行域、工作区作用域、发现代次和偏好版本精确失效;React、TUI、Peer 与 Server 不得各自维护产品状态机。 -- 归属模块的加载与卸载在锁外执行;迁移关口只阻塞外部来源读写,不阻塞项目打开或无关 Agent 任务。 -- 实现 PR 必须记录 V1/V2 快照大小和聚焦读取延迟的前后对比。没有基线时不宣称性能提升;出现明显回退时先减少返回数据或重复计算,再考虑新增缓存。 -- 后续只有出现真实消费者和独立兼容要求时,才增加新的版本化接口;不提前扩展 V1,也不为单一 V2 接口建立通用协议目录。 - -## 9. 类型化动作 - -V2 控制协议应提供闭合动作: - -- `ConnectApplication`; -- `DisconnectApplication`; -- `SetApplicationDeferred`(暂不使用); -- `SubmitApplicationReview`; -- `Refresh`; -- V2 投影需要的来源开关、策略更新和 `SetSafeMode`;既有 V1 action 保持原样,不扩充枚举。 - -每个 V2 写操作信封必须携带 `execution_domain_id`、`target_scope`、`operation_id` 和该作用域的 `expected_preference_revision`;`workspace_override` 必须携带 `workspace_scope_id`,`user_default` 必须省略它。无工作区的读取使用显式 `none`,不能当作通配符。宿主必须确认这些身份与当前连接绑定一致,不能使用控制端当前目录推断目标。宿主默认动作只能提交当前工作区范围;全执行域默认必须来自用户明确选择。 - -`operation_id` 只用于请求/响应关联和界面中的待处理操作排序,不提供业务幂等、结果缓存或跨重启重放。客户端不得在同一活动连接内为并发请求复用它;服务端也不会因 ID 相同而重放旧结果。偏好版本是唯一写并发保护:响应丢失后,客户端必须重新读取权威快照,再决定是否发起新操作;不能用相同 `operation_id` 绕过过期版本。`SubmitApplicationReview` 还必须携带 `review_id`、选择基线和有界改动项,服务端从该计划取得各归属模块的发现代次、决策键和行为版本。 - -断开连接必须停止继续同步、卸载由该连接注册的运行能力、保留必要审计与用户决定、不改写外部配置、不影响其他生态,并返回不再可用的能力摘要。重新连接只复用仍与 decision key / behavior version 匹配且策略允许的决定;权限扩大重新确认。 - -## 10. Web UI 组件边界 - -现有 `ExternalSourcesConfig` 收敛为页面 controller,并拆分为: - -- `ExternalAppsOverview`:应用首页; -- `ExternalAttentionSummary`:真实待办; -- `ExternalAppDetail`:单应用结果与管理; -- `ExternalAppReview`:批量确认; -- `ExternalAdvancedSettings`:scope、来源、冲突、诊断和 Safe Mode; -- controller/hook:读取、轮询、mutation sequencing 和恢复; -- presentation helpers:格式化展示,不做策略判断。 - -拆分必须保留现有请求序列、accepted sequence、pending mutation、scope mutation 栅栏、stale read/mutation 防护和失败恢复。UI 继续通过 infrastructure API,不直接调用 Tauri。 - -## 11. 关键场景 - -### 11.1 首次发现 OpenCode - -1. 只读发现; -2. 产品事实允许默认连接; -3. 建立持续连接; -4. 加载策略允许的低风险内容; -5. 生成高风险推荐集合; -6. 一次性显示连接结果和待办; -7. 用户提交批量 review 后加载成功项;同一行为版本不重复提示。 - -### 11.2 首次发现 Codex 或 Claude Code - -1. 只读发现; -2. 显示“发现可用配置”; -3. 不连接、不加载; -4. 一次性轻提示或 Settings 状态; -5. 用户主动连接后进入相同风险确认流程。 - -### 11.3 多应用并存 - -- 发现多个应用只增加候选; -- 只有产品事实允许且未被用户拒绝的生态可默认连接; -- 未连接应用不注册运行能力,也不参与运行时冲突; -- 一个应用的连接、审批或断开不隐式改变另一个应用; -- 已连接应用之间的真实冲突由共享归属模块生成待办。 - -### 11.4 内容更新 - -- 行为等价且风险不扩大:保持决定,静默更新摘要; -- 是否可复用旧决定由共享策略判定,宿主不猜测; -- 权限扩大:扩大部分安全拒绝,生成重新确认; -- 偏好版本过期:刷新权威状态后重新确认。 - -## 12. 可访问性、文案与 i18n - -- 保持 600px 单列阅读轴,不依赖宽屏左右主从布局; -- 每行只有一个强调主操作; -- 状态不能只靠颜色,必须有文本或图标标签; -- 批量选择、展开和恢复动作支持键盘与清晰焦点; -- 使用现有主题令牌,不新增无归属色值; -- 统一文案:“发现、连接、等待确认、已启用、需要处理、断开连接”; -- 用户可见文案进入对应 i18n namespace;日志保持英文且无 emoji。 - -## 13. 验收标准 - -### 13.1 共享契约与运行时 - -- OpenCode 默认连接,其他生态默认只发现; -- 默认策略来自共享产品事实,而不是宿主生态 ID 分支; -- 发现不注册运行能力; -- 连接只自动加载允许的低风险内容; -- 推荐集合、高风险默认不选和 safety ceiling 可验证; -- 批量确认的偏好版本/发现代次、整体失效与逐项结果可验证; -- 断开或暂不使用后不被默认策略覆盖; -- 权限扩大重新确认; -- 未连接应用不参与运行时冲突; -- 断开卸载对应能力且不改写外部配置; -- Safe Mode、旧宿主、Remote/只读场景继续安全拒绝。 -- 旧偏好迁移保留显式 disabled/discover-only、已有效使用的能力、审批与冲突决定,并以升级/重启 fixture 证明不会静默改变行为; -- user default、workspace override、本机/Peer/Remote 在 execution domain 与 workspace scope 上相互隔离; -- V1 枚举和字段保持不变,V2 只在独立协商成功后使用,双向新旧组合测试通过; -- 任务相关 `action-required` 绑定 session/turn outcome,不从全局应用快照推断。 - -### 13.2 GUI - -- 默认页只呈现总状态、单一确认入口、刷新和按应用分组的可展开树; -- 应用折叠行只显示名称、已启用能力图标和应用总开关,解释文字进入 tooltip 或按需详情; -- 打开应用自动应用推荐设置,不启动逐项配置流程;关闭应用撤下其运行能力且不修改外部文件; -- 应用树把 Hook 与其他扩展能力并列,逐级展开后同时覆盖 BitFun 原生和外部来源; -- Settings 不再单列 Agent Hooks 一级入口,旧深链导航到 External AI Apps 并展开 Hook 节点; -- 所有例外通过一个确认入口一次处理,只有首次可执行信任、权限扩大和真实冲突进入确认; -- 首次发现圆点由 Host 持久化去重,同一行为版本跨重启不重复; -- 批量默认选择与共享推荐一致,待确认单项不阻塞其他安全能力; -- 技术详情默认折叠; -- 过期读取或写操作不覆盖新状态; -- 现有 Safe Mode、审批、冲突、诊断和脱敏测试保持通过; -- type-check、i18n 和主题治理通过。 - -### 13.3 TUI 与非交互 CLI - -- GUI/TUI 对同一 fixture 的应用状态、默认策略和数量一致; -- `/extensions review` 提交同一批量决定; -- `/hooks` 完整显示并管理 BitFun 原生、已导入和可审核的外部 Hook,且不新增 `/extensions hooks`; -- `/hooks_external` 与 `/hooks-external` 仅保留解析兼容,非交互 `bitfun hooks` 契约保持稳定; -- 提示去重跨进程和宿主生效; -- 无关待办不阻塞交互; -- 非交互仅在当前任务受影响时返回 `action-required`; -- Host/Remote 差异通过共享能力与恢复动作表达。 diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index f222f4d54..ecf60b044 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -5,14 +5,13 @@ 适配器负责,本文不建立跨生态通用配置格式或脚本 SDK。BitFun 自身能力如何通过 MCP、Skill、Plugin、Hook、 SDK 或 Server 输出到外部宿主,以及内部能力组合、状态、事件和并发边界,见 [`capability-runtime-integration-design.md`](capability-runtime-integration-design.md);两条方向共用适用的身份事实和能力归属模块, -但不共用一个大一统 adapter 或状态模型。外部应用的 Settings/TUI 信息架构、默认连接、批量确认和提示去重见 -[`external-ai-app-connection-experience-design.md`](external-ai-app-connection-experience-design.md),对应实施顺序见 -[`../../plans/external-ai-app-connection-experience-plan.md`](../../plans/external-ai-app-connection-experience-plan.md)。 +但不共用一个大一统 adapter 或状态模型。Settings 和 TUI 只能把本文的来源与 integration policy 事实压缩为简短概览; +审批、冲突和可执行能力状态继续由 Tool、Agent、MCP、Hook 等真实 owner 负责。 本文同时记录当前可用端到端能力与目标架构。当前 BitFun 已具备通用外部来源目录、四条能力专属发现通道,并由 `ExternalSourceControlPlane` 负责 provider-neutral 调度、generation fencing 和故障隔离;`assembly/core` 的 `WorkspaceExternalSourceService` 负责产品级策略、偏好、聚合和运行装配,`contracts/product-domains` 提供版本化控制事实、固定动作与错误语义, -Desktop、交互式 TUI 和 Peer Host 只显示宿主所需状态,不再各自派生另一套状态机。Server 仓库中保留了只读 external-source dispatch helper,但当前 `/ws` 已直连 in-process App Server,external-source 方法尚未进入 App Server schema,生产请求会得到 `method_not_found`;因此不能把 Server 只读投影列为已交付。OpenCode Prompt Command +Desktop、交互式 TUI 和 Peer Host 只显示宿主所需状态,不再各自派生另一套状态机。App Server 已注册 external-source schema 与 handler;Embedded TUI 注入 management owner 后可以调用,通用 Server `/ws` 当前没有注入绑定可信工作区的 management owner,因此请求会得到类型化 `unsupported`,不能把通用 Server 只读投影列为已交付。OpenCode Prompt Command 适配器已接入本地用户全局/项目来源;Desktop 可查看、刷新、抑制和处理跨来源冲突,交互式 TUI(ChatMode)可列出并执行 Prompt Command;静态文件和经审阅的本地 shell 输出由共享归属模块完成装配。第二条端到端能力已让受支持的单文件 OpenCode `.js` standalone Tool 经静态 预览、来源/能力确认和同名冲突选择后进入现有 Tool Runtime;Desktop 与交互式 TUI(ChatMode)使用同一决策状态。第三条纵向 @@ -120,7 +119,7 @@ stale,界面替换为服务端返回的新 plan,并只保留“旧选择与 ### 3.1 首次发现 发现始终在后台进行。Desktop、交互式 TUI(ChatMode)和 Peer 控制界面消费事实所在 Host 的同一来源状态,但按 -宿主展示;Peer 控制界面只代理 Peer Host,不读取控制端同名来源。当前 Server `/ws` 尚未接入 external-source App Server 方法;未来只读 Web 入口必须先通过版本化 App Server schema 接入 Host 能力,不能由浏览器扫描来源: +宿主展示;Peer 控制界面只代理 Peer Host,不读取控制端同名来源。当前 Server `/ws` 已注册 external-source App Server 方法但未注入可信工作区 owner;未来只读 Web 入口必须先绑定 Host 持有的工作区范围,不能由浏览器提供任意路径或扫描来源: ```text 已发现 OpenCode 工作内容 @@ -162,8 +161,8 @@ stale,界面替换为服务端返回的新 plan,并只保留“旧选择与 Safe Mode 是执行域/工作区实例内的易失控制状态,不写入来源偏好,也不把来源伪装成 `disabled`。进入后继续发现和 展示 Command、Tool、Subagent 与 MCP,但立即撤下外部 Tool、Subagent 和 MCP 的新调用路由;Prompt Command 作为 静态模板继续可见。退出后基于当前来源版本重新协调,不能恢复已删除、已撤销或已过期审批的旧路由。 -GUI 和 TUI 都通过同一个 `SetSafeMode` 动作请求该变化,Peer Host 在事实所在 Host 执行;目标只读 Server 在完成 App Server 接线后通过 -`hostCapabilities` 明确拒绝变更,当前 Server external-source 方法仍是 `method_not_found`。所有偏好写操作携带 `expectedPreferenceRevision`,旧视图必须得到 `stale_revision` +GUI 和 TUI 都通过同一个 `SetSafeMode` 动作请求该变化,Peer Host 在事实所在 Host 执行;目标只读 Server 在注入绑定可信工作区的只读 owner 后通过 +`hostCapabilities` 明确拒绝变更,当前通用 Server 因没有 management owner 而返回类型化 `unsupported`。所有偏好写操作携带 `expectedPreferenceRevision`,旧视图必须得到 `stale_revision` 并重新读取,不能用界面本地状态覆盖并发进程的新决定。 ### 3.3 兼容来源与显式导入 @@ -299,7 +298,7 @@ generation lease 的模型绑定形态,不建立第二套 Agent Runtime。 Desktop、交互式 TUI 以及未来通过 Host 能力访问该状态的界面必须同时展示:来源请求、实际绑定、绑定方式和受影响候选数。 例如“来源请求 `sonnet`;当前工作区由用户绑定到 Primary(实际为已配置模型 X);影响 71 个 Agent”。用户可以选择其他 已配置模型、`primary`、`fast` 或保持相关候选禁用。界面不得把用户选择的替代模型描述成来源原始要求,也不得逐项重复确认 -同一绑定。目标只读 Server 在完成 App Server V1 前置切片后只投影脱敏状态,不获得写入能力;当前 Server 尚不能消费该投影。 +同一绑定。目标只读 Server 在注入绑定可信工作区的只读 management owner 后只投影脱敏状态,不获得写入能力;当前 App Server 方法已经注册,但通用 Server 尚未注入该 owner。 绑定目标的配置 ID 与 `model_runtime_binding_fingerprint` 进入既有激活审批 envelope。来源引用改变、绑定目标被删除或停用, 或者同一配置 ID 下的 provider、模型名、endpoint、认证来源及其他运行身份发生变化时,旧激活决定失效;进行中的调用继续 @@ -452,9 +451,9 @@ Theme、Keybind、完整插件清单,以及各生态新增的 managed/session/ ## 6. 架构与职责 -当前 V1 生产路径是 `Desktop/TUI/Peer Host adapter → WorkspaceExternalSourceService → ExternalSourceControlPlane → 能力专属 provider`,宿主消费 `ExternalSourceControlSnapshotV1`、目录和既有能力级动作。它没有应用级连接状态、统一批量确认计划或任务依赖结果;当前 Server App Server 也尚未暴露这条只读路径。以下 6.1-6.3 全部是连接体验交付后的目标视图,不能作为现状证据。 +当前生产路径是 `Desktop/TUI/Peer Host adapter → WorkspaceExternalSourceService → ExternalSourceControlPlane → 能力专属 provider`。宿主消费现有的 `ExternalSourceControlSnapshotV1`、公共目录、integration policy 和能力级动作。这里不再建立第二套应用连接状态、跨能力批量确认或任务依赖事件。 -### 6.1 目标逻辑视图 +### 6.1 逻辑视图 ```mermaid flowchart TB @@ -463,12 +462,11 @@ flowchart TB Ports["能力专属 provider 契约"] Discovery["ExternalSourceControlPlane\nprovider-neutral discovery"] ProductCoordinator["WorkspaceExternalSourceService\n产品级协调"] - Policy["产品能力事实 / 接入策略 / 安全上限"] + Policy["Integration policy / Safe Mode"] Store["现有原子偏好存储"] - AppView["版本化应用级读模型 + 批量确认计划"] - Catalog["公共 catalog + 能力专属详情"] + Catalog["来源控制 + 公共 catalog"] Owners["Command / Tool / Subagent / MCP / Config owner"] - Surfaces["Desktop / TUI / Peer / future read-only Server"] + Surfaces["Desktop / TUI / Peer"] Sources --- Adapters Adapters --- Ports @@ -476,35 +474,31 @@ flowchart TB Discovery --- ProductCoordinator Policy --- ProductCoordinator Store --- ProductCoordinator - ProductCoordinator --- AppView ProductCoordinator --- Catalog ProductCoordinator ---|窄 typed owner boundary| Owners Owners --- Catalog - AppView --- Surfaces Catalog --- Surfaces ``` -图中连线只表示目标稳定逻辑关系,不表示调用顺序或用户动作;连接、断开和批量确认时序只在 6.3 描述。目标中,发现、连接和加载是三个独立阶段:适配层只产生候选;现有 `ExternalSourceControlPlane` 继续只协调能力专属提供方的发现、期限、代次和故障隔离;现有 `WorkspaceExternalSourceService` 增加产品级协调职责,结合产品事实、作用域化用户决定和安全上限派生应用级连接状态与确认计划,并通过窄类型化端口请求真实能力归属模块加载或撤下。应用级读模型不携带可执行载荷,也不取代公共目录或能力专属 DTO。这里不新增第二个公开控制面类型,也不声称这些新增职责已经接线。 +图中连线表示稳定逻辑关系,不表示调用顺序。适配层只产生候选;`ExternalSourceControlPlane` 负责 provider discovery、期限、代次和故障隔离;`WorkspaceExternalSourceService` 组合 integration policy 与目录,并把真正的批准、冲突选择、加载和撤下交给能力 owner。Desktop 可以按生态对这些事实分组,但分组只属于展示,不是第二个业务状态或协议对象。 -### 6.2 目标开发视图 +### 6.2 开发视图 ```mermaid flowchart TB - ProductDomains["contracts/product-domains\n应用级状态、确认、类型化动作"] + ProductDomains["contracts/product-domains\nV1 来源、policy 与能力契约"] AssemblyExternal["assembly/external-sources\nprovider-neutral 协调器"] AssemblyCore["assembly/core\nWorkspaceExternalSourceService / 产品装配"] EcosystemAdapters["adapters/*\n生态解析与原生覆盖"] Services["services/*\n文件观察、原子存储、进程/网络"] CapabilityOwners["execution / services / core owners\nCommand、Tool、Subagent、MCP"] DesktopAdapter["apps/desktop\nTauri / Peer Host adapter"] - WebUi["web-ui\nOverview / Detail / Review / Advanced"] - Cli["apps/cli\n/extensions 与 action-required"] - Server["server / remote adapters\n目标能力约束与只读投影"] + WebUi["web-ui\n简短概览 / 能力专项设置"] + Cli["apps/cli\n/extensions /tools /agent /mcp /hooks"] WebUi --> DesktopAdapter DesktopAdapter --> AssemblyCore Cli --> AssemblyCore - Server --> AssemblyCore AssemblyCore --> AssemblyExternal AssemblyCore --> EcosystemAdapters AssemblyCore --> Services @@ -515,9 +509,9 @@ flowchart TB CapabilityOwners --> ProductDomains ``` -箭头表示目标编译期/模块依赖方指向被依赖方,不是运行时数据流。依赖方向继续遵守 interfaces/apps → assembly → adapters/services/execution → contracts;`assembly/external-sources` 只依赖 product-domain 契约,不反向依赖 Core、app 或具体生态 adapter。产品默认连接事实由 assembly 选择并通过稳定 contract 投影;React、TUI 和远端 adapter 不按生态 ID 重算默认值、应用状态或推荐集合。Server 节点只有在先把 V1 external-source 只读方法接入 App Server schema、handler/client translation 并通过 WebSocket round-trip 后才能进入这张目标图。 +箭头表示编译期依赖方指向被依赖方,不是运行时数据流。依赖方向继续遵守 interfaces/apps → assembly → adapters/services/execution → contracts;`assembly/external-sources` 只依赖 product-domain 契约,不反向依赖 Core、app 或具体生态 adapter。React 和 TUI 不复制审批、冲突或 capability owner 状态机。 -### 6.3 目标运行视图 +### 6.3 运行视图 ```mermaid sequenceDiagram @@ -525,34 +519,27 @@ sequenceDiagram participant Product as WorkspaceExternalSourceService participant Discovery as ExternalSourceControlPlane participant Adapter as Ecosystem Adapter - participant Policy as Product Policy + participant Policy as Integration Policy participant Owner as Capability Owner participant Store as Preference Store - Surface->>Product: 读取作用域化应用级 snapshot + Surface->>Product: 读取来源 control/catalog Product->>Discovery: 按 execution domain / workspace scope 刷新 Discovery->>Adapter: 只读发现候选 Adapter-->>Discovery: 来源、版本、风险摘要 Discovery-->>Product: 同代能力专属发现结果 - Product->>Policy: 计算默认连接、推荐集合与安全上限 - Policy-->>Product: OpenCode 可默认连接;其他生态只发现 - Product-->>Surface: 应用状态、主操作、review plan - Surface->>Product: ConnectApplication(scope, expected revision) - Product->>Store: 原子保存连接决定并推进权威 preference revision - Product->>Owner: 仅请求允许自动应用的低风险内容 - Owner-->>Product: 已启用 / 受限 / 失败结果 - Product-->>Surface: 连接完成摘要 - Surface->>Product: SubmitApplicationReview(scope, generations, decision keys) - Product->>Product: 重验身份、revision、generation 与 safety ceiling - Product->>Owner: 按能力类型提交批准项 - Owner-->>Product: 逐项权威结果 - Product->>Store: 原子保存有效决定 - Product-->>Surface: 同代 snapshot 与逐项结果 + Product->>Policy: 读取作用域化启停和 capability access + Product-->>Surface: 来源/应用简短概览 + Surface->>Product: 更新 integration policy 或来源启停 + Product->>Store: 校验 preference revision 后原子保存 + Surface->>Owner: 通过 /tools、/agent、/mcp 或 /hooks 处理精确对象 + Owner->>Owner: 重验 identity、version、scope 与 generation + Owner-->>Surface: 权威批准、拒绝、冲突或恢复结果 ``` -目标运行语义中,发现不会产生执行副作用。连接先在目标执行域和工作区作用域中持久化应用级决定,再只协调共享策略允许的低风险内容;批量确认仍分派到各能力归属模块,并在提交前重新校验身份、作用域、偏好版本、发现代次、决策键、行为版本、宿主能力、Safe Mode 和安全上限。 +发现不会产生执行副作用。启停只改变 integration policy 或来源状态;可执行内容在真正的能力 owner 中按精确对象确认。跨能力页面不能代替 owner,也不能批量扩大权限。 -### 6.4 现有能力与连接体验的边界 +### 6.4 现有能力边界 | 部分 | 负责 | 不能承担 | |---|---|---| @@ -563,8 +550,8 @@ sequenceDiagram | 文件观察服务 | 提供可订阅、去抖的文件变化事实 | 解释生态路径、决定优先级、提交业务状态。 | | 本地 JSON 存储服务 | 提供跨进程锁、锁内读改写和同卷原子替换;替换失败时保留旧文件 | 定义外部来源偏好 schema、冲突策略或生态语义。 | | `ExternalSourceControlPlane` | 四类来源分别刷新;同一 provider 同一时间只扫描一次;超时只影响该 provider;旧结果不能覆盖新刷新;确认最新结果后,再通知对应能力模块切换 | 按生态 ID 分支业务行为、把四类数据合并为通用资产、解析生态文件、直接提交配置、工具、权限或界面状态。 | -| `WorkspaceExternalSourceService` / 产品级协调 | 绑定执行域与工作区路由;组合产品事实、现有偏好和控制面发现结果;派生应用级投影;通过窄类型化端口分派连接、撤下和批量确认,并汇总归属模块的权威结果 | 复制提供方调度器、能力审批/冲突存储或 Runtime 归属;把无法撤下的能力宣称为已断开;成为新的公共跨生态执行 API。 | -| 版本化控制状态视图 | 根据 discovery/desired/review/runtime/support 事实生成一级状态;向宿主提供同一版本的 control/catalog、`hostCapabilities`、恢复动作和固定通用操作 | 保存第二份权威状态、携带 Prompt/凭据/可执行数据、替代能力专属审批和冲突 DTO,或让 GUI/TUI 自行推导生命周期。 | +| `WorkspaceExternalSourceService` / 产品级协调 | 绑定执行域与工作区路由;组合 integration policy、现有偏好和控制面发现结果;向能力 owner 提交窄类型化请求 | 复制提供方调度器、能力审批/冲突存储或 Runtime 归属;派生第二套应用连接状态;成为新的公共跨生态执行 API。 | +| 来源控制状态视图 | 根据 discovery、desired、owner decision、runtime 和 support 事实提供 control/catalog、`hostCapabilities`、恢复动作和固定通用操作 | 保存第二份权威状态、携带 Prompt/凭据/可执行数据、替代能力专属审批和冲突 DTO。 | | 界面状态 | 按使用范围、工作区或用户目录关系统一生成安全来源位置,清理诊断文本中的已知绝对路径,并按 `Source / Command / Tool / Subagent` 资源类型路由诊断 | 让 GUI/TUI 解析 provider 诊断码前缀、识别 `.opencode`、`.claude` 等私有目录结构,或接收原始用户/工作区路径。 | | 冲突解析 | 对独立 provider 或产品本地可执行能力的同名候选建立版本敏感内容摘要;未选择时不激活,选择后只在内容摘要不变时复用。现有 Skill 固定根顺序由 Skill 归属模块独立维护 | 用 adapter 优先级静默覆盖另一生态或本地可执行能力,或把选择写回外部文件。 | | 激活策略与能力归属模块 | 根据风险、用户选择、组织上限和执行位置决定自动应用、等待确认或限制 | 修改生态加载顺序或把策略拒绝伪装成解析失败。 | @@ -589,10 +576,7 @@ provider discovery 必须是可独立调度的 request/result,不在协调器 未来网络 provider 仍应实现协作式超时和取消, 但不改变目录、冲突或产品入口契约。 -控制请求保持闭合且类型化:严格 V1 只保留已有 `Refresh`、`SetSourceEnabled` 和 `SetSafeMode`;应用级连接体验在独立协商后的 V2 增加 -`ConnectApplication`、`DisconnectApplication`、`SetApplicationDeferred` 和 `SubmitApplicationReview`。批量 review 只封装 -一组带执行域、workspace route、能力类型、generation、decision key 和 behavior version 的选择,并由产品级协调 owner 分派给现有能力归属模块;它不能成为携带 -任意数据的通用执行 API。能力专属执行参数和调用时权限继续由各归属模块的类型明确契约承担。错误以 `code + stage + retryable + +控制请求保持闭合且类型化:现有来源 control 保留 `Refresh`、`SetSourceEnabled` 和 `SetSafeMode`,应用/生态启停复用 integration policy mutation。能力审批、冲突和执行参数继续由各归属模块的类型明确契约承担,不增加跨能力批量动作。错误以 `code + stage + retryable + correlationId/causationId + recoveryActions` 表达;`detail` 只用于有界诊断,界面和远端协议不得解析文本 决定控制流。日志只记录动作、阶段、关联 ID、错误类别和脱敏对象身份;产品打点可在同一结果上叠加,但不得反向改变状态。 @@ -602,9 +586,7 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 ## 7. 状态与提示规则 -本节定义底层来源/能力的正交生命周期状态;面向 Settings 首页和 TUI `/extensions` 的五种应用级摘要、优先级和主操作,统一见 -[外部 AI 应用连接与管理详细设计](external-ai-app-connection-experience-design.md#3-产品状态模型)。宿主不得把底层状态直接拼成第二套 -应用级规则,也不能用应用摘要替代底层事实。 +本节定义底层来源/能力的正交生命周期状态。Settings 首页和 TUI `/extensions` 可以隐藏不必要的技术细节并生成简短摘要,但不得建立第二套应用级状态规则,也不能用摘要替代底层事实。 | 用户状态 | 含义 | |---|---| @@ -625,8 +607,7 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 - 用户关闭、确认、断开连接或选择暂不使用后,同一内容/行为与风险摘要版本不再主动提示;普通数量变化只更新应用摘要。 - 再次主动提示仅限当前任务确实因待确认能力受阻或降级,或者已确认内容发生实质权限扩大。与当前任务无关的更新失败、来源删除和未连接应用变化只更新状态与恢复动作。 - 普通文件变化、多个同源错误和多项目全局更新按应用/来源聚合;详情进入设置页或 CLI 状态,每次重载最多产生一条摘要,不用 Toast 展示字段级错误。 -- 非交互入口只有在当前操作实际依赖待确认资产时才返回类型化 `action-required`;无关待办只进入结构化状态或 - `stderr` 摘要,不阻塞当前操作,也不自动批准。 +- 非交互入口不等待人工确认,也不从全局待办推断特殊任务结果;能力不可用时返回普通失败且不自动批准。 ## 8. 分阶段落地与验收 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index ed904873d..2e9e5d15e 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -7,10 +7,8 @@ 内置扩展边界见 [`product-customization-blueprint.md`](product-customization-blueprint.md);CLI 产品入口和配置 兼容见 [`cli-product-line-design.md`](cli-product-line-design.md);HarmonyOS PC 原生 CLI/TUI 平台规约见 [`platform-portability-design.md`](platform-portability-design.md)。跨专题实施顺序见 -[`../plans/product-architecture-evolution-plan.md`](../plans/product-architecture-evolution-plan.md)。外部 AI 工作内容架构、应用级连接详细设计与对应执行计划分别见 -[`external-ai-work-sources-design.md`](extensions/external-ai-work-sources-design.md)、 -[`external-ai-app-connection-experience-design.md`](extensions/external-ai-app-connection-experience-design.md)和 -[`external-ai-app-connection-experience-plan.md`](../plans/external-ai-app-connection-experience-plan.md);OpenCode 扩展总矩阵、配置资产、插件执行、 +[`../plans/product-architecture-evolution-plan.md`](../plans/product-architecture-evolution-plan.md)。外部 AI 工作内容架构见 +[`external-ai-work-sources-design.md`](extensions/external-ai-work-sources-design.md);OpenCode 扩展总矩阵、配置资产、插件执行、 终端插件和外部集成适配分别见 [`opencode-extension-compatibility.md`](extensions/opencode-extension-compatibility.md)、 [`opencode-config-assets-adapter-design.md`](extensions/opencode-config-assets-adapter-design.md)、 @@ -638,8 +636,8 @@ flowchart LR Node/Bun 和第三方 JS/TS 的子进程;插件启停与贡献生命周期仍由既有来源和能力归属模块管理。 - 外部来源的 Command、Tool、Subagent、MCP 仍保留能力专属 DTO 和 owner,但它们的发现调度统一由 `ExternalSourceControlPlane` 持有;当前 Desktop/TUI/Peer 的控制事实只通过版本化的 product-domain 只读视图共享, - 不复制生态 payload、界面状态机或远端专用 DTO。Server 的 external-source helper 当前未接入 App Server schema,生产 `/ws` - 返回 `method_not_found`;只有完成 V1 read-only schema、handler/client translation 和 WebSocket round-trip 后,Server 才进入该共享边界。 + 不复制生态 payload、界面状态机或远端专用 DTO。App Server 已注册 external-source schema、handler 和 client translation;Embedded Host + 注入 management owner 后可以调用。通用 Server `/ws` 当前没有绑定可信工作区的 management owner,因此返回类型化 `unsupported`;只有注入 Host 持有的作用域化 owner 并通过 WebSocket round-trip 后,Server 才交付该共享边界。 - 每个生态适配层独立保留该生态的外部格式、来源顺序和调用语义,并映射到 BitFun 归属模块;它本身不成为新的 业务归属模块,也不能依赖或修改兄弟生态 adapter。通用目录、`ExternalSourceControlPlane` 和能力归属模块只依赖开放生态 ID、 来源限定身份与能力专属 provider 契约,不按 OpenCode、Codex 或 Claude Code 分支行为。 @@ -817,10 +815,10 @@ flowchart LR | 当前入口 | 已有能力 | 明确边界 | |---|---|---| -| Desktop | 使用 `product-full`;显示外部来源、审批、冲突、诊断和 Host 能力;目标增加应用级读模型、默认连接事实和批量确认 DTO | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | -| CLI / TUI | 使用显式 Core owner feature closure(`agent-runtime`、`canvas-runtime`、`external-sources`、`plugin-runtime`、`ssh-remote`);现有 `/extensions` 支持来源状态、刷新、Safe Mode 和来源开关,统一 `/hooks`(旧 `/hooks_external` 为别名)、`/tools` 和 `/agents` 保留各自专项职责 | 应用级摘要、首次连接、`/extensions review` 批量确认和任务相关 `action-required` 仍是目标能力,完成条件以对应详细设计和 P6 端到端证据为准;生态解析仍在适配器,不启动第二套 Agent Runtime;远程能力未接入时不回退本机 | +| Desktop | 使用 `product-full`;Settings 从现有来源目录和 integration policy 生成简短应用概览,具体审批与冲突仍进入 Tool、Agent、MCP 或 Hook owner | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | +| CLI / TUI | 使用显式 Core owner feature closure(`agent-runtime`、`canvas-runtime`、`external-sources`、`plugin-runtime`、`ssh-remote`);`/extensions` 只提供状态、启停和刷新,`/hooks`、`/tools`、`/agent` 和 `/mcp` 处理各自能力 | 非交互不等待权限输入,也不从全局状态或错误文本推断特殊任务结果;生态解析仍在适配器;远程能力未接入时不回退本机 | | ACP | 使用 `DeliveryProfile::Acp`、Runtime Parts,以及 `agent-runtime`/`canvas-runtime`/`external-sources`/`ssh-remote` Core owner feature | load 成功后才发布活动状态;close 排空后再卸载;完整历史、Canvas 工具物化、兼容指令来源和配置仍由 Core/ACP 管理 | -| Peer / Server | Peer Host 执行真实工作区操作;当前 HTTP Server 使用 `product-full` 组装 Embedded Runtime,并通过 `/ws` 暴露 App Server,但 external-source 方法尚未进入 schema、当前返回 `method_not_found` | 控制端不替远端发现或执行;Server external-source 只读投影须先完成真实 App Server 接线,且 loopback 单用户边界不扩展到远程/多用户;SSH Remote 未接入时返回不支持 | +| Peer / Server | Peer Host 执行真实工作区操作;通用 HTTP Server 未绑定可信 workspace owner 时明确返回不支持 | 控制端不替远端发现或执行;loopback 单用户边界不扩展到远程/多用户;SSH Remote 未接入时返回不支持 | | Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 | | HarmonyOS 手机 Remote | phone-only ArkTS 远程入口 | 不等于 HarmonyOS PC 本地 Runtime、CLI/TUI 或 GUI | @@ -837,7 +835,7 @@ Shared Agent Runtime 是第一方多实例的目标部署,不是上表新增 底层来源与能力继续使用[外部 AI 工作内容设计](extensions/external-ai-work-sources-design.md#7-状态与提示规则)定义的 已发现、已应用、可用、需确认、更新中、沿用上一版本、部分受限、暂时过期、已移除/已停用和不可用,并附带 -原因与恢复建议。目标状态下,Settings 首页和 TUI `/extensions` 将消费[应用级连接详细设计](extensions/external-ai-app-connection-experience-design.md#34-面向用户的应用级状态)派生的五种摘要;当前生产入口仍消费 V1 来源/能力控制事实,不能据目标文档宣称应用级连接已经交付。目标摘要不能替代底层事实,宿主也不能自行重算优先级。Host 的准备完成、重启、暂停、不支持或失败只作为详情映射。现有代码中的过渡状态只能展示为“静态预览、未执行”,不能因为进入来源清单就误报为已应用、已连接或可用。 +原因与恢复建议。Settings 首页和 TUI 可以把这些事实压缩为简短应用/来源概览,但不能建立第二套连接、审批或任务结果状态机,也不能因为进入来源清单就误报为已应用或可用。 ## 7. 完成判定 diff --git a/docs/plans/external-ai-app-connection-experience-plan.md b/docs/plans/external-ai-app-connection-experience-plan.md deleted file mode 100644 index 9fc57e62b..000000000 --- a/docs/plans/external-ai-app-connection-experience-plan.md +++ /dev/null @@ -1,637 +0,0 @@ -# 外部 AI 应用连接体验执行计划 - -> 本计划把[外部 AI 工作内容总体架构](../architecture/extensions/external-ai-work-sources-design.md)和[外部 AI 应用连接与管理详细设计](../architecture/extensions/external-ai-app-connection-experience-design.md)拆成可独立评审、验证和回退的实施阶段。本文不扩大任何生态的能力兼容范围;OpenCode 具体能力路线仍以[OpenCode 扩展兼容计划](opencode-extension-compatibility-plan.md)为准。 - -> **实现状态:分阶段交付。** 当前分支已完成共享 V2 应用契约、产品默认、旧偏好迁移、分页批量确认、Desktop/Peer/App Server 薄适配,以及 Desktop Settings 和交互式 TUI 的纵向切片。Web 在旧 Host 上保持严格 V1 只读回退;交互式 TUI 只在 Embedded 旧 Host 上回退 V1,未接线的 Shared Runtime 明确不支持且不会改在控制进程本地执行。通用 Server 尚未绑定可信 workspace owner,任务相关 `action-required`、非交互 CLI 结果、组合 Hook 摘要和完整跨宿主回归仍按本计划后续阶段推进,不能据此宣称支持。 - -## 1. 目标与执行原则 - -目标是在保留现有 Command、Tool、Subagent、MCP、Safe Mode、冲突和远端保护语义的前提下,把“外部 AI 应用”从能力平铺页调整为应用级连接与管理体验: - -1. 后台发现、应用连接和能力加载明确分离; -2. OpenCode 可由产品事实默认连接,Codex 与 Claude Code 默认只发现; -3. 低风险声明式内容按共享策略自动应用,可执行或权限扩大的内容进入单页批量确认; -4. Desktop、TUI、Peer 和 Server 消费同一应用级读模型、默认策略和决策结果; -5. 提示一次性、持久化去重,只在任务受阻/降级或实质权限扩大时再次主动出现; -6. 不把应用级聚合对象变成新的配置、权限或执行归属模块。 - -执行遵守以下原则: - -- 每个阶段形成可独立评审的纵向结果,不能用仅有 DTO、固定假数据或未接线组件宣称完成; -- 先以测试冻结共享契约和策略,再接宿主,再替换信息架构; -- 当前 `ExternalSourceControlSnapshotV1`、V1 动作/恢复闭合枚举、V1 宿主能力和能力专属 DTO 保持字段与行为不变;应用级读写使用独立版本化 V2 接口,V2 快照不提交用户决定或运行能力写动作并直接用于能力探测;首次 owner 激活仍可执行可重入迁移和既有后台发现; -- 所有 V2 写操作携带 `execution_domain_id`、`target_scope`、`operation_id` 和与该作用域绑定的 `expected_preference_revision`;`workspace_override` 必须携带宿主快照返回的 `workspace_scope_id`,`user_default` 必须省略。`operation_id` 只做请求/响应关联,不承诺幂等重放;偏好版本是唯一写并发保护; -- 宿主能力、Safe Mode、组织/产品安全上限和 Remote/只读限制只能收紧结果; -- React、TUI、Desktop 适配层和 Server 适配层不按生态 ID 重算默认连接、推荐集合或应用级状态; -- 不建立第二套审批存储、冲突存储、监听系统、调度器或运行时注册表; -- 先完成版本化旧偏好迁移,再启用新的默认连接;升级不能静默撤下已有效使用的能力或覆盖显式 disabled/discover-only; -- GUI 与 TUI 共享语义和契约样例,不共享布局、组件、主题键、快捷键或渲染数据结构。 - -## 2. 变更地图 - -| 责任 | 主要文件 | 计划内变更 | -|---|---|---| -| 共享应用级契约 | `src/crates/contracts/product-domains/src/external_source_control.rs` | 保持 V1 不变,独立定义 `ExternalApplicationSnapshotV2`、五种摘要状态、主操作、默认连接事实、确认计划、逐项结果和 V2 类型化动作;任务依赖结果归 Agent 事件契约,不塞入可轮询应用快照。 | -| 产品默认与能力上限 | `src/crates/assembly/core/src/external_sources.rs` 及 assembly 中现有产品能力事实归属模块 | 提供 OpenCode 默认连接、Codex/Claude Code 默认只发现的产品事实;派生推荐集合、安全上限与应用状态。 | -| 偏好、迁移与提示去重 | `src/crates/assembly/core/src/external_sources.rs` | 在现有原子偏好存储中加入作用域化连接、暂不使用、提示决定和一次性 `connection_schema_migration_version`;每个旧作用域直接生成真实连接决定,不新增第二个迁移状态机或存储。 | -| 批量确认编排 | `src/crates/assembly/core/src/external_sources.rs` | 预检整批偏好版本、发现代次和宿主条件,按能力类型分派现有归属模块,汇总逐项权威结果。 | -| Desktop/Peer/App Server 投影 | `src/apps/desktop/src/api/external_sources_api.rs`、`src/apps/desktop/src/api/remote_workspace_policy.rs`、Peer 适配层、`src/crates/interfaces/app-server{,-protocol,-client}` 与 `src/apps/server` | 保持薄适配层;先把当前缺失的 V1 Server 只读投影接入 App Server 协议、客户端和处理器,再增加独立 V2 协商和接口;声明远端策略;旧宿主保持 V1 并拒绝 V2 写操作。 | -| Runtime 任务依赖结果 | `src/crates/contracts/events/src/agentic.rs`、`src/crates/assembly/core/src/agentic`、`src/crates/interfaces/app-server{,-protocol,-client}`、`src/crates/adapters/agent-runtime-ipc`、CLI 执行生命周期 | 能力归属模块产生依赖事实,Agent Runtime 关联根/来源轮次并发布 `ExternalDependencyActionRequired`;App Server 与 Shared IPC 传输同一事件,CLI 只投影匹配当前根轮次的结果。 | -| TypeScript 基础设施 | `src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts`、`ExternalSourcesAPI.test.ts` | 保持 V1 转换不变,新增独立 V2 转换,并对作用域、发现代次、偏好版本和协议协商安全拒绝。 | -| Web UI | `src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx` 及同目录拆分组件、样式和测试 | 收敛页面控制器,增加首页、待办、详情、批量确认和高级设置的纵向单列体验。 | -| TUI/CLI | `src/apps/cli/src/modes/chat/external_review.rs`、`external_hooks.rs`、`external_sources.rs`、`src/apps/cli/src/actions.rs` | `/extensions` 应用级入口、`/extensions review`、共享提示去重和任务相关 `action-required`。 | -| i18n 与主题 | 外部来源设置页现有命名空间、CLI 自有本地化资源、现有 SCSS/主题令牌 | 新文案进入归属模块的命名空间,复用 600px 布局和主题令牌,不提高治理基线。 | - -具体文件可在实施阶段按仓库当时结构做最小调整,但责任归属和依赖方向不得改变。 - -## 3. 阶段依赖 - -```mermaid -flowchart LR - P1["P1 应用级契约与产品事实"] --> P2["P2 连接偏好与提示去重"] - P2 --> P3["P3 批量确认编排"] - P1 --> P4["P4 宿主与协议投影"] - P3 --> P4 - P4 --> P5["P5 Desktop Web UI"] - P4 --> P6["P6 TUI 与非交互 CLI"] - P5 --> P7["P7 跨宿主回归与迁移清理"] - P6 --> P7 -``` - -P1-P4 是共享语义和协议前置;P5 与 P6 可以在 P4 稳定后并行,但必须以同一契约样例验证。P7 只在 Desktop 与 TUI 都消费共享读模型后执行,不能提前删除旧投影。 - -## 4. P1:应用级契约、产品事实与状态派生 - -### 归属与范围 - -- 归属:`contracts/product-domains` 与 Product Assembly; -- 主要文件: - - `src/crates/contracts/product-domains/src/external_source_control.rs` - - `src/crates/assembly/core/src/external_sources.rs` - - 对应 crate 内已存在的 focused tests。 - -### 实施内容 - -1. 冻结 `ExternalSourceControlSnapshotV1`、V1 动作/恢复枚举和 V1 `hostCapabilities`,另行增加 `ExternalApplicationSnapshotV2`: - - `application_id` 与 `ecosystem_id`; - - `execution_domain_id`、可选但非通配的 `workspace_scope_id` 和实际连接作用域;`workspace_scope_id` 复用当前宿主的 `workspace_policy_key`,不新增路径注册或反查; - - 发现、连接、健康等正交事实; - - `已连接 / 发现可用配置 / 未发现配置 / 需要处理 / 暂时不可用`; - - 唯一 `primary_action`; - - `enabled`、`pending_review`、`blocked`、`conflict` 数量; - - 风险摘要和恢复动作; - - 确认摘要、稳定 `review_id`、推荐数量/风险、`max_selection_count` 和总数,不内嵌项目列表或可执行载荷。 -2. 另行定义 `ExternalApplicationReviewPageV2`:首次无 cursor/no-generation 打开允许 Host 在后台发现刚完成时返回当前只读计划,客户端从该响应接续;其余分页游标严格绑定执行域、工作区作用域、`review_id`、偏好版本和发现代次。每页最多 128 项,只携带稳定项目引用、显示摘要、推荐和安全上限。读取分页不能触发重新发现或能力加载,提交仍必须绑定首次响应的权威计划。 -3. 将应用级状态优先级固定在共享归属模块: - `需要处理 > 暂时不可用 > 已连接 > 发现可用配置 > 未发现配置`;Safe Mode 独立投影。 -4. 在 Product Assembly 中定义默认连接事实及原因: - - OpenCode:允许默认连接; - - Codex、Claude Code:默认只发现; - - 未注册生态、旧宿主或受限产品形态:明确不支持或只读,不猜测默认值。 -5. 从现有目录、能力控制事实和归属模块状态派生应用聚合;未连接应用不参与运行时冲突和能力注册。 -6. 推荐集合由共享策略生成,高风险项默认不推荐;宿主只展示,并只允许在安全上限内调整。 -7. 应用快照不持久化或全局聚合任务影响;任务相关结果在 P6 由 Agent Runtime 事件契约单独实现,P1 只定义供其引用的稳定应用/依赖引用。 -8. 应用级纯状态、动作和作用域规则归 `contracts/product-domains`;具体聚合、持久化和归属模块分派留在 `WorkspaceExternalSourceService`,`ExternalSourceControlPlane` 不接收产品状态职责。 - -### 测试优先顺序 - -先增加失败测试,再实现最小派生逻辑: - -- OpenCode、Codex、Claude Code 默认连接事实; -- 五种状态的优先级和 Safe Mode 独立性; -- “已连接但有能力待确认”不会错误显示为全部已启用; -- 未连接应用不进入运行时冲突; -- 未知枚举、不同发现代次或不同偏好版本均安全拒绝; -- 用户默认、工作区覆盖和不同执行域的状态互不污染; -- V1 序列化固定样例完全不变,V2 未协商时不可调用; -- 首页快照不含确认项目;分页单页不超过 128,过期游标不能与新代次拼接; -- 一个轮次的任务依赖结果不能改变另一个轮次的应用状态或退出结果; -- 高风险项默认不进入推荐集合; -- 产品、组织和宿主上限不能被宿主推荐放宽。 - -### 验证 - -```bash -cargo test -p bitfun-product-domains external_source_control -cargo test -p bitfun-core external_source -cargo check --workspace -``` - -实际 package 名以对应 `Cargo.toml` 为准;若 focused test 过滤器不能覆盖新增测试,运行受影响 crate 的完整测试,不用全 workspace 测试代替静态检查。 - -### 用户可见结果 - -无独立用户界面变化;后端能够稳定返回应用级状态、默认策略、主操作和确认计划。 - -### 退出条件 - -- Desktop/TUI 无需生态分支即可渲染同一 fixture; -- V1 消费方保持可编译、golden wire shape 和原有行为; -- 应用级状态完全由共享归属模块派生; -- V2 应用状态按执行域和工作区作用域求值,任务依赖只存在于根会话和根轮次绑定的 Agent Runtime 事件; -- 默认连接事实有产品组装测试,不存在 `ecosystem_id == "opencode"` 的宿主业务分支。 - -### 暂停条件 - -若应用级聚合需要读取能力 owner 尚未公开且无第二个真实消费方的内部状态,先设计最窄只读事实并完成 owner 评审;不得通过公开任意 payload 或复制 owner 状态绕过。 - -## 5. P2:连接、断开、暂不使用与提示去重 - -### 归属与范围 - -- 归属:`assembly/core` 的 `WorkspaceExternalSourceService`(或实施时同一现有产品级服务的私有协调单元)和现有偏好存储;`assembly/external-sources` 的 `ExternalSourceControlPlane` 只提供与提供方无关的发现结果; -- 主要文件: - - `src/crates/assembly/core/src/external_sources.rs` - - `src/crates/contracts/product-domains/src/external_source_control.rs` - - 对应持久化和并发测试。 - -### 实施内容 - -1. 在 V2 endpoint 增加闭合类型化动作: - - `ConnectApplication`; - - `DisconnectApplication`; - - `SetApplicationDeferred`; - - 保持已有 `Refresh`、`SetSourceEnabled` 和 `SetSafeMode`。 -2. 在现有偏好文件和跨进程原子更新路径中持久化: - - execution domain ID; - - `user_default` 或 `workspace_override`;workspace override 携带当前 `workspace_policy_key` 产生的 Host-local `workspace_scope_id`; - - application/ecosystem ID; - - desired connection 状态; - - 明确断开或暂不使用决定; - - notice key、内容/行为版本、风险摘要版本和用户决策状态; - - 一次性 `connection_schema_migration_version`;它只与整份文档的原子转换一起写入; - - 按 `(execution_domain_id, application_id, workspace_scope_id?)` 保存的真实连接决定与 `decision_origin`,无法归属的项直接使用 `needs_review`,不保存逐 scope 迁移进度。 -3. 在启用新默认连接前执行锁内、可重入的旧偏好迁移。`WorkspaceExternalSourceService` 启动时先建立全局迁移 gate;所有 discovery、MCP revision-key helper 和 V2 endpoint 必须等待它完成或返回明确 incompatible/needs-review 状态。该 gate 先读取原始存储存在性和 schema,再调用会通过 MCP secret/revision-key 初始化自动物化默认文件的 `external_sources_config_with_mcp_revision_key`;不得根据已经默认化的对象猜测旧文件来源: - - 新决定已存在时保持不变; - - 只有确认从未存在偏好文件的 V2 新安装写入 `config_origin=fresh_v2`,允许保持“无决定”并应用新产品默认;已有文件、legacy 默认文件和 incompatible-policy reset 都不能获得该 origin; - - 任一 legacy 文件中的 `integration_policy.enabled=false` 都保守迁移为显式未连接,并记录 `decision_origin=legacy_safety`;这包括旧版本自动写出的默认文件。它与用户显式 `SetEnabled(false)` 无法区分,因此不能让 OpenCode 默认连接覆盖。代价是部分从未主动关闭的旧用户需重新连接一次,迁移说明必须明确该安全取舍; - - 目标 user default/workspace override 下该生态明确求得 disabled/discover-only 时,同样迁移为显式未连接; - - 只有旧作用域的 `integration_policy.enabled=true` 且已有效使用某生态时才迁移为已连接;该判断晚于上一条保守未连接规则。“有效使用”要求至少一项实际访问级别为 `ask_before_use`/`auto`,或存在可按来源归属的审批、冲突决定或活动路由; - - 当前 `workspace_overrides` 的键已是 `workspace:` 加规范化工作区 SHA-256 的前 16 字节十六进制;直接把每个键作为 `workspace_scope_id` 逐项迁移,不建立路径反查。无法可靠归属应用、执行域或作用域的旧记录写为 `needs_review`,对应作用域继续由 V1 路径管理; - - 读到未知未来 `schemaMajor` 时沿用当前 incompatible-policy fail-closed:不迁移、不应用默认、不写 V2 决定、不进入偏好 update/atomic replace,byte-for-byte 保留包含 opaque policy 的原文件;用户执行既有“备份并重置”时,在同一原子更新中备份 raw policy、写入 `config_origin=incompatible_reset` 和显式未连接决定,继续保持外部执行关闭,不能转成 fresh V2; - - 先在内存中计算全部旧作用域决定,再把决定、schema migration version 和现有审批/冲突数据一次原子替换。成功时不存在部分迁移;失败保持旧文件和完整 legacy 路径,重启后重试整次转换。 -4. 默认连接只对 `fresh_v2` 或已完成迁移且确实没有显式决定的作用域生效;工作区覆盖优先于同一执行域的用户默认;明确断开、暂不使用、不兼容策略或 `decision_origin=legacy_safety` 不得被监听器、重启或重新发现覆盖。 -5. 连接先在现有权威偏好文档中提交作用域化决定并推进 preference revision,再协调允许自动应用的低风险内容;返回已启用、待确认、受限和失败摘要。 -6. 断开先撤下该 execution domain/workspace scope 上的新调用路由和由该连接注册的能力,再停止持续同步;不改写外部配置,不影响其他作用域或生态。 -7. Instruction、Skill、Hook 和复制后的原生配置仍服从各自 owner。没有来源限定撤下端口的能力必须报告 `managed_separately`/部分支持,并暂停“完整断开”交付,不能由 UI 隐藏冒充卸载。 -8. 提示规则: - - 首次发现只允许一次性非阻塞轻提示; - - 用户关闭、决定或完成处理后,同一版本不再主动提示; - - 仅当前任务受阻/降级或已确认内容权限实质扩大时再次主动提示; - - 普通数量变化、无关更新失败和来源删除只更新状态。 - -### 测试优先顺序 - -- 默认连接与显式断开/暂不使用的优先级; -- fresh V2 无文件时 OpenCode 可应用产品默认;旧版自动物化的默认文件与用户显式 `enabled=false` 都保守保持未连接,且不会被默认连接覆盖; -- legacy 配置中 disabled/discover-only、已有效使用的 Claude Code/Codex/OpenCode、无决定生态分别迁移到预期状态; -- 多个 workspace scope 的迁移要么一次全部提交,要么一个都不提交;写入失败和崩溃重启不会留下部分新状态; -- discovery、MCP revision-key 初始化与 V2 endpoint 并发首次访问时都等待同一 migration gate,不能先物化默认文件或观察半迁移状态; -- 未知未来 `schemaMajor` 保持原始 JSON、拒绝迁移和 V2 mutation;备份并重置后记录 `incompatible_reset` 且仍显式未连接,不应用 OpenCode 默认; -- future-major → backup/reset → restart fixture 证明 raw backup 保留、外部执行仍关闭,只有后续显式 ConnectApplication 才改变状态; -- stale preference revision 整个 mutation 不应用; -- 响应丢失后使用旧偏好版本重试会返回过期;客户端重读权威快照后再决定是否发送新操作,相同 `operation_id` 不能绕过版本检查或重放旧结果;同一活动连接中的并发请求不复用 ID; -- 跨进程并发更新不丢失另一个应用的决定; -- 两个工作区作用域和两个执行域的连接、提示与偏好版本相互隔离; -- watcher 更新不会重新连接用户已断开的应用; -- 断开仅卸载目标生态能力; -- notice key 在 GUI/TUI/重启之间去重; -- 权限扩大产生新风险版本,普通数量变化不产生主动提示。 - -### 验证 - -```bash -cargo test -p bitfun-core external_source -cargo check --workspace -``` - -### 用户可见结果 - -连接、断开和暂不使用具有明确完成结果;同一发现不会在多个项目、进程或宿主反复提示。 - -### 退出条件 - -- 所有连接决定和 `connection_schema_migration_version` 使用现有原子偏好存储,且没有第二套逐 scope 迁移状态机; -- 默认连接与用户显式决定的优先级可由重启测试证明; -- 断开后目标 execution domain/workspace scope 的相关新调用不可达,其他作用域和生态不受影响; -- 旧审批、拒绝、冲突和来源抑制记录在迁移后保持,只有 fingerprint 失效或权限扩大才重新确认; -- 提示去重不依赖 React local storage 或 TUI 进程内集合。 - -### 暂停条件 - -若某能力 owner 无法按来源/生态撤下路由,先补 owner 的类型化撤下能力和行为测试;不得把“UI 显示已断开”作为运行时已卸载的替代证据。 - -## 6. P3:单页批量确认与归属模块分派 - -### 归属与范围 - -- 归属:`assembly/core` 的产品级 `WorkspaceExternalSourceService` 负责预检与分派,各能力归属模块负责最终业务决定;`ExternalSourceControlPlane` 不参与审批、偏好写入或产品状态派生; -- 主要文件: - - `src/crates/contracts/product-domains/src/external_source_control.rs` - - `src/crates/assembly/core/src/external_sources.rs` - - 现有 Tool、Subagent、MCP 审批与冲突测试。 - -### 实施内容 - -1. 定义 `GetApplicationReviewPage` 只读请求: - - `execution_domain_id`、`target_scope` 与可选 `workspace_scope_id`; - - `review_id`、cursor 和页面大小;服务端将页面大小限制为 128; - - 响应只含同一偏好版本/发现代次的稳定 item reference 和脱敏显示摘要;stale cursor 要求从第一页重读; - - 从当前不可变发现结果派生,不重新扫描文件、不启动能力,也不持有偏好写锁。 -2. 定义 `SubmitApplicationReview` 请求: - - `execution_domain_id`、`target_scope`;仅 workspace override 携带 Host 快照返回的 `workspace_scope_id`; - - `review_id`; - - `operation_id`,仅用于请求/响应关联; - - `expected_preference_revision`; - - 相关 provider/owner generations; - - `selection_baseline = recommended | none`; - - 有界 `selection_overrides[]`,每项只含稳定项目引用和与基线不同的选择结果。 -3. 请求不携带命令正文、提示词、凭据值、任意执行载荷或整份确认项目。服务端用 `review_id` 查找同代不可变计划,先应用共享推荐或空集合基线,再应用改动项,并从计划取得能力类型、决策键、行为版本和归属模块代次。最终选择数量服从现有归属模块/协议上限,并由确认摘要返回 `max_selection_count`;改动项也不得超过该上限。 -4. 整批预检以下条件: - - V2 schema/协议协商、Host identity 和 capability; - - execution domain、workspace scope 与当前 Host 连接绑定; - - preference revision; - - review plan/generation; - - application connection 状态; - - Safe Mode 和 safety ceiling。 -5. 整批预检失败时不应用任何项;通过后按能力类型分派现有单项审批/冲突 owner。 -6. owner 可以逐项拒绝业务请求;响应必须返回每项 `applied / rejected / blocked / stale / failed` 等闭合结果及恢复动作,未知结果不得视为成功。 -7. 只持久化实际成功且仍与 decision key/behavior version 匹配的决定;返回与最终 preference revision 同代的新快照。 - -这里的零应用保证止于分派前预检。分派开始后若某个 owner 的事实并发变化,响应可以同时包含已应用项与类型化 stale/failed 项;本阶段不增加跨 owner 事务或回滚管理器,也不宣称批量业务执行原子化。 - -### 测试优先顺序 - -- stale revision、generation 或 Host capability 导致整批零应用; -- snapshot 只含 review summary;分页大小、总量上限、cursor 绑定和 stale 重读均按契约执行,翻页不触发重新发现; -- 推荐项跨越多页且用户未读取后续页面时,`recommended` 基线仍选择同代完整推荐集合;已查看页面的改动项准确覆盖基线,不为提交强制拉取全部页面; -- `none` 基线加选择改动项可以表达从空集合开始的选择;改动项越界、未知引用或来自另一 `review_id` 时整批拒绝; -- 作用域身份不匹配或从另一 workspace scope/Host 重放导致整批零应用; -- 两个不同 owner 的成功项共同提交; -- 一个 owner 业务拒绝时另一个成功项的逐项结果准确; -- 未知 item reference 和未知能力类型 fail closed; -- safety ceiling 阻止宿主选择高于上限的项; -- 高风险默认未选,但用户可在上限允许时显式选择; -- 重放旧 review plan 不恢复旧权限; -- 逐项结果与最终快照状态一致。 - -### 验证 - -```bash -cargo test -p bitfun-core external_source -cargo test -p bitfun-core external_tool -cargo test -p bitfun-core external_subagent -cargo test -p bitfun-core external_mcp -cargo check --workspace -``` - -过滤器以实际测试模块为准,至少覆盖本次触及的所有 owner。 - -### 用户可见结果 - -用户可以在一个 review 页面确认推荐集合;无需连续处理 Tool、Subagent、MCP 和冲突弹窗,并能看到逐项真实结果。 - -### 退出条件 - -- 整批并发保护与逐项业务结果边界清楚; -- 没有通用任意 payload API; -- owner 仍是最终批准、注册和失败事实的权威; -- 旧单项入口在迁移期间仍可工作,并与批量入口共享决定。 - -### 暂停条件 - -如果无法定义跨 owner 的原子回滚,不得宣称批量业务执行原子化;保留“整批预检原子、owner 逐项结果”的明确语义,并确保响应与快照可解释。 - -## 7. P4:Desktop、Peer、App Server 与 Server 协议投影 - -### 归属与范围 - -- 归属:各应用/传输适配层与 `interfaces/app-server` 线协议适配层; -- 主要文件: - - `src/apps/desktop/src/api/external_sources_api.rs` - - `src/apps/desktop/src/api/remote_workspace_policy.rs` - - `src/apps/cli/src/peer_host/commands/external_sources.rs` - - `src/crates/interfaces/app-server-protocol/src/external_sources.rs` 及 `method.rs`/`lib.rs` 注册 - - `src/crates/interfaces/app-server-client/src/lib.rs` - - `src/crates/interfaces/app-server/src/server/handlers/external_sources.rs` 及 Runtime/domain-to-wire conversion - - `src/apps/server/src/app_server.rs`、`src/apps/server/src/routes/external_sources.rs` 与 WebSocket round-trip tests - - `src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts` - - `src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts`。 - -### 实施内容 - -1. 保持现有 V1 DTO、Desktop/Peer endpoint、TypeScript union/allowlist 和 wire fixtures 不变;不得向 V1 action、recovery action 或 `hostCapabilities` 追加应用级字段。当前 Server `/ws` 经 `BitfunAppServer` 处理,仓库中的旧 `routes/external_sources.rs::dispatch` 已脱离生产路径并返回 `method_not_found`,不能把它当作“现有 Server adapter”。 -2. 先完成 P4a Server V1 只读前置切片: - - 在 `app-server-protocol` 定义独立的 V1 snapshot/control-snapshot method、wire DTO 和错误;`AppServer`/`AppClient` role 保持 schema-free,不登记领域方法; - - `app-server-client` 增加 typed request/response,`app-server` 只注册 handler、校验 wire contract 并转换 Runtime/domain 类型;handler 注入 `WorkspaceExternalSourceService` 的最窄只读 owner port,不持有第二份状态; - - `interfaces/app-server-client` 与 TypeScript translation 保持 V1 wire shape,Server Host 绑定其真实 workspace,不读取浏览器或控制端路径; - - Server 不注册 write handler;未知/写方法在反序列化 mutation payload 前以 method-not-found/host-capability-unavailable 拒绝; - - 用真实 `/ws` transport 做 Server bootstrap → `BitfunAppServer::serve` → handler → owner → client 的端到端 round-trip。该切片通过前,Server 不进入 V2 共享 fixture,也不得标记为只读 external-source Host。 -3. P4a 后新增不提交用户决定或运行能力写动作的 `get_external_application_snapshot_v2`,直接作为版本探测:成功响应必须是严格 V2 数据结构,并携带宿主读写能力;首次 owner 激活可执行可重入迁移和既有后台发现,确认分页不得冷启动 owner。旧宿主的传输层 method-not-found 等价于“仅 V1”。不增加独立版本信息接口,也不引入“声明支持但接口不可用”的第二种状态。 -4. 客户端只有在 V2 snapshot 校验成功后,才调用 `get_external_application_review_page_v2` 或 `apply_external_application_action_v2`。V2 snapshot/action 不与 V1 对象混合序列化;read-only Server 只登记 snapshot/review read endpoint,不登记 mutation endpoint。 -5. Desktop Tauri command 只映射结构化 request/response,不派生状态、默认策略或推荐集合。 -6. 每个新增 Desktop command 在 remote workspace policy 中声明明确策略;Remote 未支持时返回 V2 类型化 unsupported,不回退本机。 -7. Peer Host 在事实所在 Host 执行相同 V2 typed action;Host 始终校验 `execution_domain_id`,并在 workspace override/上下文存在时校验快照返回的 `workspace_scope_id` 与连接绑定;控制端只原样回传 scope id,再用 Host identity、generation 和 accepted sequence 隔离响应。 -8. 旧 Peer/Host: - - 新客户端回退显示 legacy V1 control/catalog,不把候选误报为应用级已连接; - - V2 mutation 在客户端禁用;“升级 Host”是协商失败后的本地 UI 恢复建议,不发送给旧 Host; - - 不由控制端模拟 mutation。 -9. TypeScript 为 V1/V2 使用独立 normalization;V2 严格检查 schema、作用域身份、generation、preference revision、Host capability 和 item reference,未知字段组合 fail closed。 - -### 测试优先顺序 - -- V1 Rust/TypeScript golden fixtures 在新 Host/客户端中保持完全一致; -- old client → new Host 继续只使用 V1;new client → old Host 经 method-not-found 明确回退 V1 且没有 V2 mutation; -- V2 Rust/TypeScript 序列化字段一致;V2 snapshot 成功、method-not-found 回退和未知 schema 拒绝均有契约测试; -- App Server V1 read-only 方法在真实 Server `/ws` 往返成功,且 wire fixture 与 Desktop/Peer V1 一致; -- control、catalog 和 application snapshot 同代; -- read-only Host 未注册 mutation endpoint,并在 mutation payload 解析前拒绝; -- Remote 不回退本机; -- 旧 Host 降级不会把候选误报为已连接或已启用,也不会收到未知 V2 action/recovery enum; -- Host identity、execution domain 或 workspace scope 不匹配时拒绝响应/结果; -- accepted sequence 防止旧响应覆盖新连接决定; -- 未知状态、动作、逐项结果和恢复动作安全失败。 - -### 验证 - -```bash -cargo check -p bitfun-desktop -cargo test -p bitfun-app-server -cargo test -p bitfun-server external_source -pnpm --dir src/web-ui run test:run src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts -pnpm run type-check:web -``` - -同时运行 Desktop/Peer/Server 中与 external source command 直接对应的 focused tests。 - -### 用户可见结果 - -本机 Desktop、Peer 控制界面和只读 Host 对相同应用事实给出一致状态;不支持的宿主明确说明升级、重连或切换 Host。 - -### 退出条件 - -- adapter 无生态业务分支; -- 新 Desktop commands 全部具备 remote workspace policy; -- TypeScript 对未知、未协商或不同作用域/代快照 fail closed; -- V1 wire contract 冻结,V2 只在独立 endpoint 协商后启用; -- Server V1 read-only App Server 前置切片有真实 WebSocket round-trip,不能由 dead dispatch 单元测试替代; -- 双向新旧 Host/客户端组合有契约测试,旧 Host 降级不产生执行位置 fallback。 - -## 8. P5:Desktop Web UI 信息架构 - -### 归属与范围 - -- 归属:Web UI Settings; -- 主要文件: - - `src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx` - - `src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss` - - `src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx` - - 同目录新增的聚焦组件与测试 - - `src/web-ui/src/infrastructure/config/components/common/config-page-layout.tokens.scss` - - 外部来源设置页现有 i18n namespace。 - -### 实施内容 - -1. 保留 `ExternalSourcesConfig` 作为页面 controller,继续负责读取、轮询、mutation sequencing、accepted sequence、pending mutation、scope mutation 栅栏和错误恢复。 -2. 按责任拆分: - - `ExternalAppsOverview`; - - `ExternalAttentionSummary`; - - `ExternalAppDetail`; - - `ExternalAppReview`; - - `ExternalAdvancedSettings`; - - 无策略判断的 presentation helpers。 -3. 首页使用现有 `ConfigPageLayout` 的 760px 单列阅读轴:标题、应用列表、高级设置。真实的任务相关待办通过就地提示或状态变化处理,不把无法归属的系统诊断聚合成首页数量。 -4. 每个应用行只显示应用名、一个状态、一句结果摘要和唯一主操作;有工作区时主操作明确标注“仅当前工作区”,没有工作区时先进入详情选择范围。来源路径、能力清单、冲突和诊断进入详情。 -5. 详情按“结果优先、控制后置”排列;连接完成显示生效范围、已启用、待确认和受限摘要。`user_default` 只在详情/高级设置中提供,并在提交前再次展示会影响同一执行域的所有工作区。 -6. 批量确认页面先使用快照摘要,再按需分页读取项目引用;默认只显示确认数量和“使用推荐/暂不启用”两个决定,单项名称、风险和安全上限放在折叠的调整区,不展示内部错误码、处理阶段或任意载荷。高风险默认未选。提交使用同代推荐/空集合基线和用户改动项,不为提交强制读取全部页面;首页轮询不读取项目页面。 -7. Safe Mode 在首页和详情显著展示,高级设置保留现有 source、scope、冲突、诊断和能力级管理。 -8. 首次发现只使用一次性轻提示和 Settings 导航状态;不增加启动 Modal 或常驻 banner。 -9. 所有文案进入现有 i18n namespace,颜色与状态复用主题 token,不提高主题治理基线。 - -### 测试优先顺序 - -- 五种应用状态和唯一主操作; -- “需要处理”仅在真实待办时出现; -- OpenCode 默认连接结果与 Codex/Claude 主动连接路径; -- 连接完成摘要; -- 当前工作区主操作不会改写 `user_default`;无工作区时不会直接执行全局连接;全局连接必须明确选择并二次确认范围; -- 批量默认选择严格等于共享推荐;跨页未读取项由同代推荐基线表达,已修改项只作为覆盖提交; -- 首页请求不携带 review items;打开/翻页才读取 bounded page,stale page 会整体刷新而不是混合显示; -- stale response/mutation 不覆盖新状态; -- review 整体失败和逐项失败; -- 断开与暂不使用; -- Safe Mode 显著状态; -- 旧 Host/read-only/Remote 降级; -- 键盘焦点、展开、批量选择和状态非颜色表达; -- 现有审批、冲突、诊断、scope 和脱敏回归保持通过。 - -### 验证 - -```bash -pnpm --dir src/web-ui run test:run src/infrastructure/config/components/ExternalSourcesConfig.test.tsx src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts -pnpm run type-check:web -pnpm run i18n:audit -pnpm run theme:color-audit:all -``` - -若组件拆分出独立测试文件,将这些文件加入同一次 focused test 命令。 - -### 用户可见结果 - -Settings 以应用为主入口,采用纵向单列;用户先看到连接结果和唯一下一步,高级能力管理仍可访问但不占据首页。 - -### 退出条件 - -- 首页不再平铺 Tool、Subagent、MCP、来源和诊断; -- controller 的竞态保护有回归测试; -- UI 不包含 OpenCode/Codex/Claude 默认策略分支; -- 现有高级操作没有被隐藏为不可达; -- type-check、focused tests、i18n 和主题治理通过。 - -### 暂停条件 - -若拆分组件需要重写现有 controller 并改变 mutation 顺序,先保留 controller,仅提取纯展示组件;不得以视觉改版为由同时重构请求状态机。 - -## 9. P6:TUI `/extensions` 与非交互 CLI - -### 归属与范围 - -- 归属:能力归属模块产生阻塞事实,Agent Runtime 拥有根任务结果与父子关系;App Server/Shared IPC 只传输,`src/apps/cli` 只投影交互和退出结果; -- 主要文件: - - `src/crates/contracts/events/src/agentic.rs` - - `src/crates/contracts/runtime-ports/src/lib.rs` - - `src/crates/assembly/core/src/agentic/coordination/coordinator.rs` 及真实外部能力解析/调用 owner - - `src/crates/interfaces/app-server-protocol/src/tui.rs`、`src/crates/interfaces/app-server-protocol/src/event.rs`、`src/crates/interfaces/app-server-client/src/lib.rs` 与 event round-trip tests - - `src/crates/interfaces/app-server/src/server/event_forwarder.rs` 及 handler/conversion tests - - `src/crates/adapters/agent-runtime-ipc/src/protocol.rs` 及 Shared Runtime client/server tests - - `src/apps/cli/src/modes/chat/external_review.rs` - - `src/apps/cli/src/modes/chat/external_hooks.rs` - - `src/apps/cli/src/modes/chat/external_sources.rs` - - `src/apps/cli/src/actions.rs` - - `src/apps/cli/src/peer_host/commands/external_sources.rs` - - `src/apps/cli/src/modes/exec/lifecycle.rs` - - 对应 parser、action registry、snapshot、事件和输出测试。 - -### 实施内容 - -1. `/extensions` 使用共享应用级快照展示应用、状态、数量、默认策略、主操作和 Safe Mode。 -2. 增加连接、断开、暂不使用和详情动作;默认命令作用于当前工作区并在输出中显示范围,全执行域默认必须使用明确参数/确认路径。parser、help、palette/action registry 与 dispatch 从同一 action 定义保持一致。 -3. `/extensions review` 使用共享 review summary,并按需读取有界 item page: - - 默认采用推荐集合; - - 高风险默认不选; - - 支持查看技术详情和调整; - - 用同代推荐/空集合基线和有界改动项提交同一类型化批量动作,不强制读取全部页面; - - 逐项展示权威结果。 -4. `/tools`、`/agent`、`/mcp` 和 `/hooks` 保留专项/高级管理,不复制首次连接向导。 -5. 删除仅进程内有效的重复提示判断,改为读取共享 notice/user decision facts;首次发现不阻塞聊天。 -6. 复用现有提交身份,不新增任务 ID:`AgentSubmissionResult` 仍只返回 accepted/turn ID;根 `session_id + turn_id` 唯一标识本次任务,子代理来源由现有 `SubagentSessionLinked` 追溯。 -7. 能力 owner 在真实解析或调用路径因未连接、待批量确认或权限扩大而阻止一个被请求的外部依赖时,返回类型化依赖事实。Agent Runtime 用 turn-local collector 聚合并发布新的 `AgenticEvent::ExternalDependencyActionRequired`,事件至少包含: - - `execution_domain_id` 与可选、非通配的 `workspace_scope_id`; - - 根 `session_id + turn_id`; - - `origin_session_id + origin_turn_id + origin_tool_call_id?`; - - 依赖引用、风险摘要、`can_degrade` 与闭合恢复动作。 -8. Runtime 使用现有 `SubagentSessionLinked(parent_session_id, parent_dialog_turn_id, parent_tool_call_id)` 递归追溯子代理来源。只有根 turn 仍在等待来源 tool call 时,子代理阻断事实才聚合给根;无关、后台或已脱离等待链的子代理结果不改变根任务。聚合事件必须在对应根任务结束事件前发出;并发根 turn 之间不共享 collector。 -9. 通过已有 Agent 事件路径端到端传输,而不是新增 CLI 私有旁路: - - `bitfun-events` 拥有事件 wire contract;应用级 product-domain DTO 只提供稳定 dependency reference,不拥有任务结果; - - App Server 继续通过 `agent/event` 的 `AgenticEventEnvelope` 转发,但新闭合事件是 wire 扩展:提升 `app-server-protocol::PROTOCOL_VERSION`,按每连接协商版本过滤 `ExternalDependencyActionRequired`。旧协议连接继续接收其已知事件但绝不能收到新 variant;若实现无法可靠逐连接过滤,就必须同步提升 `MIN_PROTOCOL_VERSION` 并在 initialize 时拒绝旧客户端,不能让其在事件流中反序列化失败; - - `app-server-client` 只有在 Host 协商到新增版本后才解释该事件;新客户端连接旧 App Server Host 时明确报告“任务依赖结果不支持”,不从结束文本猜测; - - Shared Runtime 继续通过 `RuntimeIpcEvent::Agent` 转发。由于 IPC 是严格版本协议,新增事件时同步提升 `PROTOCOL_VERSION`,旧 client/server 在握手失败后明确降级,不能混读; - - Peer/Remote fanout 必须保留根任务和来源身份,不得重写为控制端 workspace。 -10. 非交互 CLI 只缓存与当前 `execution_domain_id + workspace_scope_id? + root session + root turn` 全部匹配的事件;不可降级的事件在根任务结束后投影为类型化 `action-required`,可降级事件保留为结构化警告并沿用真实结束结果。不得从轮询应用快照、任意子代理事件或错误文本推断退出状态。 -11. stdout/stderr 与现有结构化输出契约保持不变;不得把交互式选择提示写入非交互 stdout。 - -### 测试优先顺序 - -- `/extensions` parser、help、palette 和 dispatch 一致; -- GUI/TUI 对同一 fixture 的状态、默认策略、数量和主操作一致; -- GUI/TUI 默认连接或断开只改变当前 workspace scope;全执行域操作必须明确选择,结果摘要显示最终生效范围; -- `/extensions review` 默认选择与共享推荐一致,跨页未访问项与用户改动项的结果和 GUI 相同; -- stale review 重新读取,不重放旧决定; -- 首次提示跨进程去重; -- 无关待办不阻塞聊天或非交互任务; -- 根 turn 直接命中 pending capability 时,在 terminal event 前收到匹配的 `ExternalDependencyActionRequired` 并返回 `action-required`; -- 通过 `SubagentSessionLinked` 证明依赖的 child blocking fact 聚合到根;无关/后台 child、错误 parent tool-call 或已断开的依赖边不影响根; -- 两个并发根 turn 的事件不串扰,来自另一 execution domain、workspace scope、session 或 turn 的事件被拒绝; -- App Server Embedded 与 Shared Runtime IPC 对同一事件 fixture 的字段、顺序和 terminal 结果等价;Shared 新旧协议版本在握手处 fail closed; -- new App Server Host → protocol v2/v3 client 不发送未知 outcome variant;新版本 client → old Host 不提交/不期待该能力;协商新版本时完整 round-trip; -- Peer/Remote 转发保留 root/origin identity 且不回退控制端 workspace; -- read-only/Remote/旧 Host 输出明确恢复动作; -- `/tools`、`/agent`、`/mcp`、`/hooks` 原有职责和兼容别名保持通过。 - -### 验证 - -```bash -cargo test -p bitfun-cli external -cargo test -p bitfun-cli action -cargo test -p bitfun-events external_dependency -cargo test -p bitfun-app-server agent_event -cargo test -p bitfun-agent-runtime-ipc agent_event -cargo check -p bitfun-cli -``` - -同时运行 action registry 和相关 slash command 的现有 focused tests。 - -### 用户可见结果 - -TUI 与 Desktop 共享“发现—连接—加载”的心智和决定;CLI 用户通过 `/extensions` 完成首次连接和批量确认,能力专项入口继续可用。 - -### 退出条件 - -- GUI/TUI golden fixture 一致; -- 交互提示不阻塞普通输入; -- 非交互只对当前 execution domain、workspace scope、根 session/turn 的不可降级任务相关待办返回 `action-required`; -- direct root、linked subagent、unrelated/background subagent、并发 roots、Embedded/Shared 和 Peer/Remote 路径都有端到端事件证据; -- TUI 无生态默认策略分支,且不共享 GUI 布局或组件 schema。 - -## 10. P7:跨宿主回归、迁移与清理 - -### 归属与范围 - -- 归属:Product Assembly、Desktop、Web UI、CLI 共同完成; -- 范围:共享 fixtures、i18n、主题、旧投影退场和文档同步。 - -### 实施内容 - -1. 建立同一组跨宿主 fixture,至少覆盖: - - 首次发现并默认连接 OpenCode; - - 首次发现但不连接 Codex/Claude Code; - - 多应用并存; - - 已连接且部分待确认; - - 权限扩大; - - 连接失败、沿用上一版本和 Host 不支持; - - Safe Mode; - - stale revision/generation; - - 断开后重新发现; - - 当前任务相关与无关待办; - - user default 与 workspace override; - - 本机、Peer、Remote execution domain 隔离; - - old client/new Host、new client/old Host; - - fresh V2 无文件、legacy 自动物化默认文件/显式 false、disabled/discover-only、已有效使用、无法归属、多个 workspace scope 原子转换和 future-major incompatible policy。 -2. 对比 Rust read model、TypeScript normalization、Desktop 展示和 TUI 文本中的状态、默认策略、数量、主操作及恢复动作。 -3. 验证 P2 的原位旧偏好迁移和切换: - - 全部 scope 决定与 `connection_schema_migration_version` 一次原子提交,崩溃/写失败保持完整旧文件,不存在部分完成状态; - - legacy 自动物化默认文件、显式 false 和 disabled/discover-only 均不被新默认覆盖;只有明确 `fresh_v2` 无文件初始化可应用 OpenCode 默认;已有效使用的 Claude Code/Codex/OpenCode 能力、审批和冲突决定不因升级静默撤下; - - future-major incompatible policy byte-for-byte 保留包含 opaque policy 的原文件,拒绝迁移、默认连接、MCP secret 自动写入和 V2 mutation;备份并重置后写入 `incompatible_reset` 并保持显式未连接,直到用户主动连接; - - 无法归属的旧状态继续留在 legacy V1 路径并要求审阅,直到有明确迁移决定; - - Instruction、Skill、Hook 和复制后的原生配置按各自 owner 验证,不被应用连接误删。 -4. 迁移旧入口: - - 保留能力专项操作; - - 删除 React/TUI 中重复的状态优先级、默认连接和提示去重逻辑; - - 只有所有生产宿主切换且回归通过后,才删除不再消费的 legacy 聚合字段或 action; - - 只要仍有旧 Host/客户端,V1 wire contract 和 endpoint 就继续保留;V2 不复用或扩展 V1 闭合枚举;Server 只有在 App Server V1 read-only round-trip 交付后才计入生产宿主。 -5. 更新架构、详细设计、CLI 架构和实现状态;不能把目标能力写成已交付。 -6. 记录首页快照和确认分页的序列化大小、聚焦读取延迟及前后对比;读取不得重新扫描、启动外部能力或持有偏好写锁。明显回退必须先减少返回数据或重复计算,不能无基线地增加缓存。 -7. 复核远端策略、日志脱敏、i18n、主题、仓库卫生和未跟踪生成文件。 - -### 综合验证 - -```bash -pnpm run fmt:rs -cargo check --workspace -cargo test -p bitfun-core external_source -cargo test -p bitfun-cli external -cargo check -p bitfun-desktop -pnpm --dir src/web-ui run test:run src/infrastructure/config/components/ExternalSourcesConfig.test.tsx src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts -pnpm run type-check:web -pnpm run i18n:contract:test -pnpm run i18n:audit -pnpm run theme:color-audit:all -pnpm run check:repo-hygiene -``` - -仅在实际触及对应范围时运行 i18n contract 或全主题审计;Rust 和 Web UI 的最小必需检查仍按仓库根 `AGENTS.md` 执行。 - -### 退出条件 - -- Desktop、TUI、Peer 与已完成 App Server 前置切片的 Server 对共享 fixture 的应用事实一致; -- 连接、批量确认、断开、提示去重和任务相关 `action-required` 均有端到端证据; -- 现有 Safe Mode、能力审批、冲突、诊断、脱敏和竞态测试保持通过; -- 无宿主按生态 ID 重算产品事实; -- 未连接应用不加载能力、不参与运行时冲突; -- Remote/read-only 不回退本机; -- legacy 升级不改变显式策略、已有效使用的能力、审批或冲突决定,失败可重试且不产生半迁移; -- 连接、提示和确认按执行域与工作区作用域隔离;任务结果按执行域、工作区作用域、根会话和根轮次隔离,并沿用子代理来源关系; -- 首页快照与确认分页保持有界,且性能对比没有未解释的明显回退; -- V1 wire fixtures 不变,V2 协商及双向新旧组合通过; -- 旧字段和逻辑只在确认无生产消费方后删除; -- 文档明确区分当前能力与目标状态。 - -## 11. 提交与评审边界 - -建议按 P1-P7 分为独立提交或 PR;P5 与 P6 可以在 P4 后并行。每个提交必须: - -1. 包含自己的失败测试、实现和最小验证; -2. 说明修改了哪个稳定 contract/owner,是否影响旧 Host; -3. 不混入新的生态能力解析、OpenCode package runtime、聊天历史迁移或显式配置导入; -4. 不提高 i18n/theme 治理基线来掩盖新增债务; -5. 不删除与旧消费方仍有关联的公共 V1 符号; -6. 在评审描述中列出实际执行的 focused commands 和剩余由 CI 覆盖的范围。 - -出现以下任一情况应停止当前阶段并回到架构评审: - -- 需要让 UI/TUI 解析生态原始 payload; -- 需要新增跨能力任意执行 DTO; -- 需要通过本地 fallback 掩盖 Remote/Host 不支持; -- 需要绕过 owner 才能批量批准或卸载; -- 需要为连接体验建立第二套偏好、权限、冲突或 watcher 系统; -- 无法在不改变现有能力运行语义的情况下实现应用聚合。 diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index ad4378da9..1fe145be9 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -829,8 +829,6 @@ export const externalSourceContractPublicApiEntries = [ export const externalSourceControlPublicApiEntries = [ 'EXTERNAL_SOURCE_CONTROL_SCHEMA_V1', - 'EXTERNAL_APPLICATION_SCHEMA_V2', - 'EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS', 'ExternalSourceOperationStage', 'ExternalSourceRecoveryActionV1', 'ExternalSourceDiscoveryState', @@ -846,39 +844,6 @@ export const externalSourceControlPublicApiEntries = [ 'ExternalSourceSurfaceSnapshotV1', 'ExternalSourceControlActionV1', 'ExternalSourceControlRequestV1', - 'ExternalApplicationTargetScopeV2', - 'ExternalApplicationDesiredConnectionV2', - 'ExternalApplicationUserDecisionV2', - 'ExternalApplicationDiscoveryStateV2', - 'ExternalApplicationConnectionStateV2', - 'ExternalApplicationHealthV2', - 'ExternalApplicationEffectiveStatusV2', - 'ExternalApplicationPrimaryActionV2', - 'derive_external_application_status_v2', - 'ExternalApplicationDefaultConnectionPolicyV2', - 'ExternalApplicationRiskLevelV2', - 'ExternalApplicationSafetyCeilingV2', - 'ExternalApplicationRecoveryActionV2', - 'ExternalApplicationHostCapabilitiesV2', - 'ExternalApplicationRiskSummaryV2', - 'ExternalApplicationReviewItemKindV2', - 'ExternalApplicationReviewItemRefV2', - 'ExternalApplicationOwnerGenerationV2', - 'ExternalApplicationReviewCategoryCountV2', - 'ExternalApplicationReviewRecommendationSummaryV2', - 'ExternalApplicationReviewSummaryV2', - 'ExternalApplicationSummaryV2', - 'ExternalApplicationSnapshotV2', - 'ExternalApplicationReviewItemV2', - 'ExternalApplicationReviewPageRequestV2', - 'ExternalApplicationReviewPageV2', - 'ExternalApplicationReviewSelectionBaselineV2', - 'ExternalApplicationReviewSelectionOverrideV2', - 'ExternalApplicationControlActionV2', - 'ExternalApplicationControlRequestV2', - 'ExternalApplicationOperationOutcomeV2', - 'ExternalApplicationReviewItemResultV2', - 'ExternalApplicationControlResultV2', ].map((symbol) => externalSourceControlEntry( symbol, @@ -1020,9 +985,6 @@ export const externalSourceCorePublicApiEntries = [ 'EXTERNAL_SOURCE_CONTROL_SCHEMA_V1', 'get_external_source_control_snapshot', 'apply_external_source_control_action', - 'get_external_application_snapshot_v2', - 'get_external_application_review_page_v2', - 'apply_external_application_action_v2', ].map((symbol) => externalSourceControlEntry( symbol, @@ -1120,16 +1082,16 @@ export const externalSourceCorePublicApiEntries = [ ].map((symbol) => ({ symbol, owner: 'bitfun-core external source composition facade', - consumer: 'Desktop settings navigation and CLI/TUI external application entry points', + consumer: 'Desktop external-source host adapter and Web settings navigation', verification: - 'core acknowledgement persistence and execution-domain scoping tests, plus Desktop and TUI first-discovery hint tests', - p0: 'first-discovery hint for external applications shared by GUI and TUI', + 'core acknowledgement persistence and execution-domain scoping tests, Desktop command contract tests, and Web settings awareness tests', + p0: 'first-discovery notification for external-source settings', contractSlice: contractSlices.externalSourceCommandContract, wireImpact: true, rationale: - 'both surfaces must derive "an external application the user has not seen" from one owner, otherwise GUI and TUI drift; awareness stays outside the preference-revision contract because it grants nothing and only suppresses a hint', + 'the Web settings notification must remain stable across refreshes and workspace changes without treating acknowledgement as permission or policy', exit: - 'remove once the versioned application-level read model owns notice state, together with its cross-surface deduplication tests', + 'remove only if Web settings no longer persists first-discovery awareness or a reviewed owner-scoped replacement preserves the same workspace isolation', })), ...[ 'ExternalToolActivationState', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index ff6465a51..24a30dfac 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1527,12 +1527,6 @@ export function runManifestParserSelfTest({ 'ExternalSourceControlRequestV1', 'ExternalSourceOperationStage', 'ExternalSourceRecoveryActionV1', - 'EXTERNAL_APPLICATION_SCHEMA_V2', - 'ExternalApplicationSnapshotV2', - 'ExternalApplicationReviewPageV2', - 'ExternalApplicationControlActionV2', - 'ExternalApplicationControlRequestV2', - 'ExternalApplicationControlResultV2', ]) { if (!externalSourceControlPublicApiRule?.allowedSymbolEntries.some( (entry) => entry.symbol === requiredSymbol @@ -1583,9 +1577,6 @@ export function runManifestParserSelfTest({ 'ExternalSourceControlRequestV1', 'get_external_source_control_snapshot', 'apply_external_source_control_action', - 'get_external_application_snapshot_v2', - 'get_external_application_review_page_v2', - 'apply_external_application_action_v2', ]) { if (!externalSourceCorePublicApiRule?.allowedSymbolEntries.some( (entry) => entry.symbol === requiredSymbol diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 5b8b35a86..c85e35918 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -586,9 +586,9 @@ static ACTION_SPECS: &[ActionSpec] = &[ }, ActionSpec { id: "extensions", - name: "External integrations", + name: "Extensions", aliases: &["/extensions"], - description: "View external source status and Safe Mode", + description: "View and manage extensions", contexts: CHAT, availability: ActionAvailability::Always, handler: ActionHandler::Extensions, @@ -603,7 +603,7 @@ static ACTION_SPECS: &[ActionSpec] = &[ id: "hooks", name: "Hooks", aliases: &["/hooks"], - description: "Review and manage native and imported Hooks", + description: "View and manage Hooks", contexts: CHAT, availability: ActionAvailability::Always, handler: ActionHandler::NativeHooks, @@ -625,7 +625,7 @@ static ACTION_SPECS: &[ActionSpec] = &[ default_bindings: &[], fallback_bindings: &[], shortcut_field: None, - palette: palette("Tools", false), + palette: None, shortcut_label: None, slash_on_startup: false, }, @@ -1327,6 +1327,7 @@ pub(crate) fn slash_actions(state: ActionState) -> Vec { .filter(|spec| { spec.available(state) && !spec.aliases.is_empty() + && spec.id != "hooks_external" && (state.context != ActionContext::Startup || spec.slash_on_startup) }) .flat_map(|spec| { @@ -2484,7 +2485,22 @@ mod tests { assert_eq!(tools.handler, ActionHandler::Tools); let extensions = action_for_alias("/extensions", ActionContext::Chat).unwrap(); assert_eq!(extensions.handler, ActionHandler::Extensions); - assert!(extensions.description.contains("Safe Mode")); + assert_eq!(extensions.name, "Extensions"); + assert_eq!(extensions.description, "View and manage extensions"); + assert_eq!( + action_for_alias("/hooks_external", ActionContext::Chat) + .expect("legacy Hook alias remains parseable") + .handler, + ActionHandler::ExternalHooks + ); + assert!(!slash_actions(ActionState::chat(false, false)) + .iter() + .any(|action| action.id == "hooks_external")); + assert!(!palette_actions(ActionState::chat(false, false)) + .iter() + .any(|action| action.id == "hooks_external")); + let hooks = action_for_alias("/hooks", ActionContext::Chat).unwrap(); + assert_eq!(hooks.description, "View and manage Hooks"); let agents = action_for_alias("/agent", ActionContext::Chat).unwrap(); assert_eq!(agents.handler, ActionHandler::OpenAgentSelector); assert_eq!(agents.description, "Switch modes and manage agents"); diff --git a/src/apps/cli/src/agent/tui_client.rs b/src/apps/cli/src/agent/tui_client.rs index bec380e23..7c85d763d 100644 --- a/src/apps/cli/src/agent/tui_client.rs +++ b/src/apps/cli/src/agent/tui_client.rs @@ -22,11 +22,7 @@ use bitfun_app_server_protocol::workspace::*; use bitfun_app_server_protocol::worktree::*; use bitfun_core_types::SessionUsageReport; use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; -use bitfun_product_domains::external_source_control::{ - ExternalApplicationControlRequestV2, ExternalApplicationControlResultV2, - ExternalApplicationReviewPageRequestV2, ExternalApplicationReviewPageV2, - ExternalApplicationSnapshotV2, ExternalSourceControlRequestV1, -}; +use bitfun_product_domains::external_source_control::ExternalSourceControlRequestV1; use bitfun_product_domains::external_sources::{ ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourcePublicSnapshot, NativePromptCommandDescriptor, PromptCommandShellReviewDecision, @@ -467,49 +463,6 @@ impl TuiAgentClient { .map_err(external_source_backend_error) } - pub(crate) async fn external_application_snapshot_v2( - &self, - force_refresh: bool, - ) -> std::result::Result { - self.backend - .external_application_snapshot_v2(ExternalApplicationSnapshotRequestV2 { - workspace_path: Some(self.workspace_path_string()), - force_refresh, - }) - .await - .map(|response| response.0) - .map_err(external_source_backend_error) - } - - pub(crate) async fn external_application_review_page_v2( - &self, - request: ExternalApplicationReviewPageRequestV2, - ) -> std::result::Result { - self.backend - .external_application_review_page_v2(ExternalApplicationReviewPageRequest { - workspace_path: Some(self.workspace_path_string()), - request, - }) - .await - .map(|response| response.0) - .map_err(external_source_backend_error) - } - - pub(crate) async fn apply_external_application_action_v2( - &self, - request: ExternalApplicationControlRequestV2, - ) -> std::result::Result { - let operation_id = request.operation_id.clone(); - self.backend - .apply_external_application_action_v2(ExternalApplicationActionRequest { - workspace_path: Some(self.workspace_path_string()), - request, - }) - .await - .map(|response| response.0) - .map_err(|error| external_source_backend_error_with_id(error, Some(&operation_id))) - } - pub(crate) fn subscribe_external_source_updates( &self, ) -> Result> { diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index c5f41ffa3..82aa7d651 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -557,8 +557,9 @@ pub(crate) struct ChatMode { external_tool_notice_key: Option, external_tool_review_snapshot: Option, external_tool_mutation_rx: Option>, + external_control_snapshot: + Option, external_control_mutation_rx: Option>, - external_application_ui: ExternalApplicationUiState, external_agent_notice_key: Option, external_agent_review_snapshot: Option, external_agent_mutation_rx: Option>, @@ -623,8 +624,8 @@ impl ChatMode { external_tool_notice_key: None, external_tool_review_snapshot: None, external_tool_mutation_rx: None, + external_control_snapshot: None, external_control_mutation_rx: None, - external_application_ui: ExternalApplicationUiState::default(), external_agent_notice_key: None, external_agent_review_snapshot: None, external_agent_mutation_rx: None, diff --git a/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs index 9e680b9a7..4aeb97037 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -1,16 +1,6 @@ // Pure projections and review text derived from the external-source catalog. use bitfun_product_domains::external_source_control::{ - ExternalApplicationControlActionV2, ExternalApplicationControlRequestV2, - ExternalApplicationControlResultV2, ExternalApplicationEffectiveStatusV2, - ExternalApplicationHealthV2, ExternalApplicationOperationOutcomeV2, - ExternalApplicationPrimaryActionV2, ExternalApplicationRecoveryActionV2, - ExternalApplicationReviewItemRefV2, ExternalApplicationReviewPageRequestV2, - ExternalApplicationReviewPageV2, ExternalApplicationReviewSelectionBaselineV2, - ExternalApplicationReviewSelectionOverrideV2, ExternalApplicationRiskLevelV2, - ExternalApplicationSafetyCeilingV2, ExternalApplicationSnapshotV2, - ExternalApplicationTargetScopeV2, ExternalSourceDesiredState, ExternalSourceEffectiveStatus, - ExternalSourceRecoveryActionV1, ExternalSourceSupportState, - EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, EXTERNAL_APPLICATION_SCHEMA_V2, + ExternalSourceDesiredState, ExternalSourceEffectiveStatus, ExternalSourceRecoveryActionV1, }; fn external_command_projections( @@ -271,7 +261,7 @@ enum ExternalControlUiAction { Show, Refresh, SetSafeMode(bool), - SetSourceEnabled { source_key: String, enabled: bool }, + SetSourceEnabled { source_index: usize, enabled: bool }, } fn parse_external_control_action(arguments: &str) -> Result { @@ -280,863 +270,105 @@ fn parse_external_control_action(arguments: &str) -> Result Ok(ExternalControlUiAction::Refresh), ["safe-mode", "on"] => Ok(ExternalControlUiAction::SetSafeMode(true)), ["safe-mode", "off"] => Ok(ExternalControlUiAction::SetSafeMode(false)), - ["source", "enable", source_key] => Ok(ExternalControlUiAction::SetSourceEnabled { - source_key: (*source_key).to_string(), + ["enable", source_number] => Ok(ExternalControlUiAction::SetSourceEnabled { + source_index: parse_positive_index(Some(source_number), "extension number")?, enabled: true, }), - ["source", "disable", source_key] => Ok(ExternalControlUiAction::SetSourceEnabled { - source_key: (*source_key).to_string(), + ["disable", source_number] => Ok(ExternalControlUiAction::SetSourceEnabled { + source_index: parse_positive_index(Some(source_number), "extension number")?, enabled: false, }), - _ => Err("usage: /extensions [status | refresh | safe-mode on | safe-mode off | source enable | source disable ]".to_string()), - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ExternalReviewDirection { - Next, - Previous, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ExternalReviewNavigation { - Open, - Move { - expected_cursor: Option, - previous_cursors: Vec>, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ExternalApplicationUiAction { - Show, - Refresh, - ConnectApplication { - application_id: String, - }, - DisconnectApplication { - application_id: String, - }, - DeferApplication { - application_id: String, - }, - OpenReview, - ReviewNext, - ReviewPrevious, - SetReviewItem { - item_ref: ExternalApplicationReviewItemRefV2, - selected: bool, - }, - SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2, - immediate_selection: Option<(ExternalApplicationReviewItemRefV2, bool)>, - }, -} - -struct ExternalApplicationReviewUiState { - page: ExternalApplicationReviewPageV2, - previous_cursors: Vec>, - selection_overrides: Vec<(ExternalApplicationReviewItemRefV2, bool)>, -} - -enum ExternalApplicationAsyncResult { - Snapshot(ExternalApplicationSnapshotV2), - LegacySnapshot(bitfun_app_server_protocol::external_source::ExternalSourceSnapshotResponse), - ReviewPage { - page: ExternalApplicationReviewPageV2, - navigation: ExternalReviewNavigation, - }, - Mutation { - result: ExternalApplicationControlResultV2, - snapshot: ExternalApplicationSnapshotV2, - }, -} - -fn should_fallback_to_legacy_external_status( - shared: bool, - error: &ExternalSourceOperationError, -) -> bool { - !shared - && matches!( - error.code, - ExternalSourceOperationErrorCode::HostCapabilityUnavailable - | ExternalSourceOperationErrorCode::Unsupported - ) -} - -enum ExternalApplicationPendingRequest { - Snapshot { - force_refresh: bool, - }, - ReviewPage { - request: ExternalApplicationReviewPageRequestV2, - navigation: ExternalReviewNavigation, - }, - Mutation(ExternalApplicationControlRequestV2), -} - -struct ExternalApplicationMutationResult { - action: ExternalApplicationUiAction, - result: std::result::Result, -} - -#[derive(Default)] -struct ExternalApplicationUiState { - snapshot: Option, - review: Option, - pending_rx: Option>, -} - -impl ExternalApplicationUiState { - fn replace_snapshot(&mut self, snapshot: ExternalApplicationSnapshotV2) -> Result<(), String> { - snapshot.validate().map_err(str::to_string)?; - let keep_review = self.review.as_ref().is_some_and(|review| { - snapshot.review_summary.as_ref().is_some_and(|summary| { - summary.review_id == review.page.review_id - && snapshot.preference_revision == review.page.preference_revision - && snapshot.execution_domain_id == review.page.execution_domain_id - && snapshot.workspace_scope_id == review.page.workspace_scope_id - }) - }); - if !keep_review { - self.review = None; - } - self.snapshot = Some(snapshot); - Ok(()) - } - - fn snapshot(&self) -> Result<&ExternalApplicationSnapshotV2, String> { - self.snapshot.as_ref().ok_or_else(|| { - "External application V2 status is unavailable; run /extensions status".to_string() - }) - } - - fn can_mutate(&self) -> Result<(), String> { - let snapshot = self.snapshot()?; - let scope_allowed = if snapshot.workspace_scope_id.is_some() { - snapshot.host_capabilities.can_manage_workspace_override - } else { - snapshot.host_capabilities.can_manage_user_default - }; - if snapshot.host_capabilities.can_mutate && scope_allowed { - Ok(()) - } else { - Err("This host is read-only for external application changes.".to_string()) - } - } - - fn target_scope( - snapshot: &ExternalApplicationSnapshotV2, - ) -> (ExternalApplicationTargetScopeV2, Option) { - match snapshot.workspace_scope_id.clone() { - Some(workspace_scope_id) => ( - ExternalApplicationTargetScopeV2::WorkspaceOverride, - Some(workspace_scope_id), - ), - None => (ExternalApplicationTargetScopeV2::UserDefault, None), - } - } - - fn control_request( - &self, - operation_id: &str, - action: ExternalApplicationControlActionV2, - ) -> Result { - self.can_mutate()?; - let snapshot = self.snapshot()?; - let (target_scope, workspace_scope_id) = Self::target_scope(snapshot); - let request = ExternalApplicationControlRequestV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: snapshot.execution_domain_id.clone(), - workspace_scope_id, - target_scope, - operation_id: operation_id.to_string(), - expected_preference_revision: snapshot.preference_revision, - action, - }; - request.validate().map_err(str::to_string)?; - Ok(request) - } - - fn open_review_page_request(&self) -> Result { - let snapshot = self.snapshot()?; - if !snapshot.host_capabilities.can_read_review { - return Err("This host cannot read the external application review.".to_string()); - } - let summary = snapshot - .review_summary - .as_ref() - .ok_or_else(|| "No external application review is pending.".to_string())?; - let (target_scope, workspace_scope_id) = Self::target_scope(snapshot); - Ok(ExternalApplicationReviewPageRequestV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: snapshot.execution_domain_id.clone(), - workspace_scope_id, - target_scope, - review_id: summary.review_id.clone(), - preference_revision: snapshot.preference_revision, - expected_generations: Vec::new(), - cursor: None, - page_size: EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, - }) - } - - fn review_page_request( - &self, - direction: ExternalReviewDirection, - ) -> Result< - ( - ExternalApplicationReviewPageRequestV2, - ExternalReviewNavigation, + _ => Err( + "usage: /extensions [status | refresh | enable | disable ]" + .to_string(), ), - String, - > { - let review = self - .review - .as_ref() - .ok_or_else(|| "Open /extensions review before changing review pages.".to_string())?; - let (cursor, previous_cursors) = match direction { - ExternalReviewDirection::Next => { - let cursor = review.page.next_cursor.clone().ok_or_else(|| { - "The external application review has no next page.".to_string() - })?; - let mut history = review.previous_cursors.clone(); - history.push(review.page.cursor.clone()); - (Some(cursor), history) - } - ExternalReviewDirection::Previous => { - let mut history = review.previous_cursors.clone(); - let cursor = history.pop().ok_or_else(|| { - "The external application review has no previous page.".to_string() - })?; - (cursor, history) - } - }; - let request = ExternalApplicationReviewPageRequestV2 { - schema_version: review.page.schema_version, - execution_domain_id: review.page.execution_domain_id.clone(), - workspace_scope_id: review.page.workspace_scope_id.clone(), - target_scope: review.page.target_scope, - review_id: review.page.review_id.clone(), - preference_revision: review.page.preference_revision, - expected_generations: review.page.expected_generations.clone(), - cursor: cursor.clone(), - page_size: EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, - }; - Ok(( - request, - ExternalReviewNavigation::Move { - expected_cursor: cursor, - previous_cursors, - }, - )) - } - - fn replace_review_page( - &mut self, - page: ExternalApplicationReviewPageV2, - navigation: ExternalReviewNavigation, - ) -> Result<(), String> { - page.validate().map_err(str::to_string)?; - let snapshot = self.snapshot()?; - let summary = snapshot.review_summary.as_ref().ok_or_else(|| { - "The external application review is stale; refresh /extensions.".to_string() - })?; - let (expected_scope, expected_workspace_scope_id) = Self::target_scope(snapshot); - let opening = matches!(&navigation, ExternalReviewNavigation::Open); - if page.execution_domain_id != snapshot.execution_domain_id - || page.workspace_scope_id != expected_workspace_scope_id - || page.target_scope != expected_scope - || page.preference_revision != snapshot.preference_revision - || (!opening && page.review_id != summary.review_id) - { - return Err( - "The external application review is stale; refresh /extensions.".to_string(), - ); - } - if matches!(&navigation, ExternalReviewNavigation::Move { .. }) - && self - .review - .as_ref() - .is_none_or(|review| review.page.expected_generations != page.expected_generations) - { - return Err( - "The external application review generation is stale; reopen /extensions review." - .to_string(), - ); - } - let (expected_cursor, previous_cursors, keep_overrides) = match navigation { - ExternalReviewNavigation::Open => (None, Vec::new(), false), - ExternalReviewNavigation::Move { - expected_cursor, - previous_cursors, - } => (expected_cursor, previous_cursors, true), - }; - if page.cursor != expected_cursor { - return Err( - "The external application review page is stale; reopen /extensions review." - .to_string(), - ); - } - let selection_overrides = if keep_overrides { - self.review - .take() - .map(|review| review.selection_overrides) - .unwrap_or_default() - } else { - Vec::new() - }; - self.review = Some(ExternalApplicationReviewUiState { - page, - previous_cursors, - selection_overrides, - }); - Ok(()) - } - - fn review_item_selected(&self, index: usize) -> Result { - let review = self - .review - .as_ref() - .ok_or_else(|| "Open /extensions review before selecting items.".to_string())?; - let item = review.page.items.get(index).ok_or_else(|| { - "That review item is not on the current page; reopen /extensions review.".to_string() - })?; - Ok(review - .selection_overrides - .iter() - .find_map(|(item_ref, selected)| (item_ref == &item.item_ref).then_some(*selected)) - .unwrap_or(item.recommended)) - } - - fn set_review_item_selected(&mut self, index: usize, selected: bool) -> Result<(), String> { - self.can_mutate()?; - let review = self - .review - .as_mut() - .ok_or_else(|| "Open /extensions review before selecting items.".to_string())?; - let item = review.page.items.get(index).ok_or_else(|| { - "That review item is not on the current page; reopen /extensions review.".to_string() - })?; - if selected == item.recommended { - review - .selection_overrides - .retain(|(item_ref, _)| item_ref != &item.item_ref); - } else if let Some((_, current)) = review - .selection_overrides - .iter_mut() - .find(|(item_ref, _)| item_ref == &item.item_ref) - { - *current = selected; - } else { - review - .selection_overrides - .push((item.item_ref.clone(), selected)); - } - Ok(()) - } - - fn review_submit_request( - &self, - operation_id: &str, - selection_baseline: ExternalApplicationReviewSelectionBaselineV2, - immediate_selection: Option<(&ExternalApplicationReviewItemRefV2, bool)>, - ) -> Result { - let review = self - .review - .as_ref() - .ok_or_else(|| "Open /extensions review before applying it.".to_string())?; - let mut selection_overrides = if matches!( - selection_baseline, - ExternalApplicationReviewSelectionBaselineV2::Recommended - ) { - review - .selection_overrides - .iter() - .map( - |(item_ref, selected)| ExternalApplicationReviewSelectionOverrideV2 { - item_ref: item_ref.clone(), - selected: *selected, - }, - ) - .collect::>() - } else { - Vec::new() - }; - if let Some((item_ref, selected)) = immediate_selection { - let baseline_selected = match selection_baseline { - ExternalApplicationReviewSelectionBaselineV2::Recommended => review - .page - .items - .iter() - .find_map(|item| (item.item_ref == *item_ref).then_some(item.recommended)) - .unwrap_or(false), - ExternalApplicationReviewSelectionBaselineV2::None => false, - }; - selection_overrides.retain(|selection| selection.item_ref != *item_ref); - if selected != baseline_selected { - selection_overrides.push(ExternalApplicationReviewSelectionOverrideV2 { - item_ref: item_ref.clone(), - selected, - }); - } - } - self.control_request( - operation_id, - ExternalApplicationControlActionV2::SubmitApplicationReview { - review_id: review.page.review_id.clone(), - expected_generations: review.page.expected_generations.clone(), - selection_overrides, - selection_baseline, - }, - ) - } -} - -fn external_application_for_number( - state: &ExternalApplicationUiState, - value: Option<&str>, -) -> Result { - let index = parse_positive_index(value, "application number")?; - state - .snapshot()? - .applications - .get(index) - .cloned() - .ok_or_else(|| { - "That application is not in the displayed V2 snapshot; run /extensions status." - .to_string() - }) -} - -fn external_review_item_for_number( - state: &ExternalApplicationUiState, - value: Option<&str>, -) -> Result { - let index = parse_positive_index(value, "review item number")?; - state - .review - .as_ref() - .and_then(|review| review.page.items.get(index)) - .map(|item| item.item_ref.clone()) - .ok_or_else(|| { - "That item is not in the displayed review page; reopen /extensions review.".to_string() - }) -} - -fn parse_external_application_action( - arguments: &str, - state: &ExternalApplicationUiState, -) -> Result { - let mut parts = arguments.split_whitespace(); - let Some(command) = parts.next() else { - return Ok(ExternalApplicationUiAction::Show); - }; - if command.eq_ignore_ascii_case("status") { - if parts.next().is_none() { - return Ok(ExternalApplicationUiAction::Show); - } - } else if command.eq_ignore_ascii_case("refresh") { - if parts.next().is_none() { - return Ok(ExternalApplicationUiAction::Refresh); - } - } else if command.eq_ignore_ascii_case("connect") - || command.eq_ignore_ascii_case("disconnect") - || command.eq_ignore_ascii_case("defer") - { - state.can_mutate()?; - let application = external_application_for_number(state, parts.next())?; - if parts.next().is_some() { - return Err(format!("usage: /extensions {command} ")); - } - let allowed = if command.eq_ignore_ascii_case("connect") { - application.primary_action == ExternalApplicationPrimaryActionV2::Connect - } else if command.eq_ignore_ascii_case("disconnect") { - application.effective_status == ExternalApplicationEffectiveStatusV2::Connected - } else { - application.effective_status == ExternalApplicationEffectiveStatusV2::NeedsAttention - }; - if !allowed { - return Err(format!( - "Application {} no longer offers that next action; run /extensions status.", - application.application_id - )); - } - return if command.eq_ignore_ascii_case("connect") { - Ok(ExternalApplicationUiAction::ConnectApplication { - application_id: application.application_id.clone(), - }) - } else if command.eq_ignore_ascii_case("disconnect") { - Ok(ExternalApplicationUiAction::DisconnectApplication { - application_id: application.application_id.clone(), - }) - } else { - Ok(ExternalApplicationUiAction::DeferApplication { - application_id: application.application_id.clone(), - }) - }; - } else if command.eq_ignore_ascii_case("review") { - let Some(review_command) = parts.next() else { - return Ok(ExternalApplicationUiAction::OpenReview); - }; - if review_command.eq_ignore_ascii_case("next") && parts.next().is_none() { - state.review_page_request(ExternalReviewDirection::Next)?; - return Ok(ExternalApplicationUiAction::ReviewNext); - } - if review_command.eq_ignore_ascii_case("previous") && parts.next().is_none() { - state.review_page_request(ExternalReviewDirection::Previous)?; - return Ok(ExternalApplicationUiAction::ReviewPrevious); - } - if review_command.eq_ignore_ascii_case("include") - || review_command.eq_ignore_ascii_case("exclude") - { - state.can_mutate()?; - let item_ref = external_review_item_for_number(state, parts.next())?; - if parts.next().is_some() { - return Err(format!( - "usage: /extensions review {review_command} " - )); - } - return Ok(ExternalApplicationUiAction::SetReviewItem { - item_ref, - selected: review_command.eq_ignore_ascii_case("include"), - }); - } - if review_command.eq_ignore_ascii_case("allow") && parts.next().is_none() { - state.can_mutate()?; - let review = state - .review - .as_ref() - .ok_or_else(|| "Open /extensions review before applying it.".to_string())?; - if review.page.total_count != 1 || review.page.items.len() != 1 { - return Err("Use /extensions review include , then /extensions review apply for multiple items.".to_string()); - } - let item = &review.page.items[0]; - if item.safety_ceiling == ExternalApplicationSafetyCeilingV2::Blocked { - return Err("This item cannot be enabled; use /extensions review deny.".to_string()); - } - return Ok(ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, - immediate_selection: Some((item.item_ref.clone(), true)), - }); - } - if review_command.eq_ignore_ascii_case("apply") && parts.next().is_none() { - state.can_mutate()?; - return Ok(ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, - immediate_selection: None, - }); - } - if review_command.eq_ignore_ascii_case("deny") && parts.next().is_none() { - state.can_mutate()?; - return Ok(ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::None, - immediate_selection: None, - }); - } - } - Err("usage: /extensions [status | refresh | connect | disconnect | defer | review [next | previous | include | exclude | allow | apply | deny]]".to_string()) -} - -fn external_application_status_label(status: ExternalApplicationEffectiveStatusV2) -> &'static str { - match status { - ExternalApplicationEffectiveStatusV2::Connected => "Connected", - ExternalApplicationEffectiveStatusV2::ConfigurationAvailable => "Configuration available", - ExternalApplicationEffectiveStatusV2::NoConfiguration => "No configuration", - ExternalApplicationEffectiveStatusV2::NeedsAttention => "Needs attention", - ExternalApplicationEffectiveStatusV2::TemporarilyUnavailable => "Temporarily unavailable", - } -} - -fn external_application_health_label(health: ExternalApplicationHealthV2) -> &'static str { - match health { - ExternalApplicationHealthV2::Healthy => "healthy", - ExternalApplicationHealthV2::Degraded => "degraded", - ExternalApplicationHealthV2::Unavailable => "unavailable", } } -fn external_application_recovery_label( - action: &ExternalApplicationRecoveryActionV2, -) -> &'static str { - match action { - ExternalApplicationRecoveryActionV2::Refresh => "refresh", - ExternalApplicationRecoveryActionV2::Retry => "retry", - ExternalApplicationRecoveryActionV2::ReconnectHost => "reconnect host", - ExternalApplicationRecoveryActionV2::Review => "review", - ExternalApplicationRecoveryActionV2::UpgradeHost => "upgrade host", - ExternalApplicationRecoveryActionV2::ViewReason => "view reason", - ExternalApplicationRecoveryActionV2::ExitSafeMode => "exit safe mode", - ExternalApplicationRecoveryActionV2::ResolveConflict => "resolve conflict", - ExternalApplicationRecoveryActionV2::InstallRuntime => "install runtime", +fn external_control_status_text( + control: &bitfun_product_domains::external_source_control::ExternalSourceControlSnapshotV1, +) -> String { + let mut lines = vec!["Extensions".to_string(), String::new()]; + if control.safe_mode { + lines.push("External access is paused. Resume: /extensions safe-mode off".to_string()); + lines.push(String::new()); } -} -fn external_application_overview_text(snapshot: &ExternalApplicationSnapshotV2) -> String { - let mut lines = vec!["External applications".to_string(), String::new()]; - if snapshot.safe_mode { - lines.push("Safe Mode: on".to_string()); + if control.sources.is_empty() { + lines.push("No extensions found.".to_string()); } - let scope_can_mutate = snapshot.host_capabilities.can_mutate - && if snapshot.workspace_scope_id.is_some() { - snapshot.host_capabilities.can_manage_workspace_override - } else { - snapshot.host_capabilities.can_manage_user_default + for (index, source) in control.sources.iter().enumerate() { + let effective = match source.effective_status { + ExternalSourceEffectiveStatus::Discovering => "Checking", + ExternalSourceEffectiveStatus::Disabled => "Off", + ExternalSourceEffectiveStatus::ReviewRequired => "Needs permission", + ExternalSourceEffectiveStatus::Conflict => "Needs attention", + ExternalSourceEffectiveStatus::Active => "On", + ExternalSourceEffectiveStatus::Degraded => "Needs attention", + ExternalSourceEffectiveStatus::Unsupported => "Unavailable", + ExternalSourceEffectiveStatus::Available => "Available", + ExternalSourceEffectiveStatus::Removed => "Not found", }; - for (index, application) in snapshot.applications.iter().enumerate() { let number = index + 1; - lines.push(format!( - "{number}. {} - {}", - application.display_name, - external_application_status_label(application.effective_status) - )); - let mut facts = Vec::new(); - if application.health != ExternalApplicationHealthV2::Healthy { - facts.push(format!( - "Health: {}", - external_application_health_label(application.health) - )); - } - if application.blocked_count > 0 { - facts.push(format!("{} blocked", application.blocked_count)); - } - if application.conflict_count > 0 { - facts.push(format!("{} conflicts", application.conflict_count)); - } - if !application.recovery_actions.is_empty() { - facts.push(format!( - "Recovery: {}", - application - .recovery_actions - .iter() - .map(external_application_recovery_label) - .collect::>() - .join(", ") - )); - } - if !facts.is_empty() { - lines.push(format!(" {}", facts.join("; "))); - } - if scope_can_mutate { - match application.primary_action { - ExternalApplicationPrimaryActionV2::Connect => { - lines.push(format!(" Next: /extensions connect {number}")) - } - ExternalApplicationPrimaryActionV2::Review => {} - ExternalApplicationPrimaryActionV2::Retry => { - lines.push(" Next: /extensions refresh".to_string()) - } - ExternalApplicationPrimaryActionV2::None - | ExternalApplicationPrimaryActionV2::View - | ExternalApplicationPrimaryActionV2::ViewReason => {} - } - if application.effective_status == ExternalApplicationEffectiveStatusV2::Connected { - lines.push(format!(" Disconnect: /extensions disconnect {number}")); - } + lines.push(format!("{number}. {} - {effective}", source.display_name)); + if control.host_capabilities.can_manage_sources { + let (verb, command) = match source.desired { + ExternalSourceDesiredState::Enabled => ("Disable", "disable"), + ExternalSourceDesiredState::Disabled => ("Enable", "enable"), + }; + lines.push(format!(" {verb}: /extensions {command} {number}")); } } - if snapshot.review_summary.is_some() && snapshot.host_capabilities.can_read_review { - lines.push(String::new()); - lines.push("Review: /extensions review".to_string()); - } - lines.join("\n") -} - -fn external_application_risk_label(risk: ExternalApplicationRiskLevelV2) -> &'static str { - match risk { - ExternalApplicationRiskLevelV2::Low => "low", - ExternalApplicationRiskLevelV2::Moderate => "moderate", - ExternalApplicationRiskLevelV2::High => "high", - } -} -fn external_application_review_text(state: &ExternalApplicationUiState) -> Result { - let review = state - .review - .as_ref() - .ok_or_else(|| "Open /extensions review before displaying it.".to_string())?; - let mut lines = vec![ - "External application review".to_string(), - String::new(), - format!("{} items total", review.page.total_count), - ]; - let can_mutate = state.can_mutate().is_ok(); - if can_mutate { - let direct = review.page.total_count == 1 - && review.page.items.len() == 1 - && review.page.items[0].safety_ceiling != ExternalApplicationSafetyCeilingV2::Blocked; - if direct { - lines.push("Enable: /extensions review allow".to_string()); - lines.push("Keep disabled: /extensions review deny".to_string()); - } else { - lines.push("Apply selections: /extensions review apply".to_string()); - lines.push("Keep all disabled: /extensions review deny".to_string()); - } - } - lines.push(String::new()); - lines.push("Adjust individual items:".to_string()); - for (index, item) in review.page.items.iter().enumerate() { - let selected = state.review_item_selected(index)?; - lines.push(format!( - "{}. [{}] {} [{}]", - index + 1, - if selected { "x" } else { " " }, - item.display_name, - external_application_risk_label(item.risk_level) - )); - } - if !review.previous_cursors.is_empty() { - lines.push("Previous: /extensions review previous".to_string()); - } - if review.page.next_cursor.is_some() { - lines.push("Next: /extensions review next".to_string()); + if !control.host_capabilities.can_manage_sources { + lines.push("This connection can only show extension status.".to_string()); } - if can_mutate { - lines.push("Adjust: /extensions review ".to_string()); + if control.sources.iter().any(|source| { + matches!( + source.effective_status, + ExternalSourceEffectiveStatus::ReviewRequired | ExternalSourceEffectiveStatus::Conflict + ) + }) { + lines.push("Manage permissions: /tools, /agent, /mcp, or /hooks".to_string()); } - Ok(lines.join("\n")) -} - -fn external_control_review_text( - control: &bitfun_product_domains::external_source_control::ExternalSourceControlSnapshotV1, -) -> String { - external_control_review_text_impl(control, true) -} -fn external_control_read_only_review_text( - control: &bitfun_product_domains::external_source_control::ExternalSourceControlSnapshotV1, -) -> String { - external_control_review_text_impl(control, false) -} - -fn external_control_review_text_impl( - control: &bitfun_product_domains::external_source_control::ExternalSourceControlSnapshotV1, - include_mutations: bool, -) -> String { - use bitfun_product_domains::external_source_control::{ - ExternalCapabilityKindV1, ExternalSourceRuntimeState, - }; - - let mut lines = vec![ - "External integrations".to_string(), - String::new(), - format!( - "Safe Mode: {}", - if control.safe_mode { "on" } else { "off" } - ), - format!("Execution domain: {}", control.execution_domain_id), - format!("Generation: {}", control.refresh_generation), - format!("Sources: {}", control.sources.len()), - ]; - if control.safe_mode { - lines.push( - "New external Tool, Agent, and MCP calls are blocked; calls already in progress are not cancelled." - .to_string(), - ); - lines.push( - "Safe Mode applies only to this Host process and execution domain; restarting the Host turns it off." - .to_string(), - ); - } - for source in &control.sources { - let desired = match source.desired { - ExternalSourceDesiredState::Enabled => "enabled", - ExternalSourceDesiredState::Disabled => "disabled", - }; - let effective = match source.effective_status { - ExternalSourceEffectiveStatus::Discovering => "discovering", - ExternalSourceEffectiveStatus::Disabled => "disabled", - ExternalSourceEffectiveStatus::ReviewRequired => "review required", - ExternalSourceEffectiveStatus::Conflict => "conflict", - ExternalSourceEffectiveStatus::Active => "active", - ExternalSourceEffectiveStatus::Degraded => "degraded", - ExternalSourceEffectiveStatus::Unsupported => "unsupported", - ExternalSourceEffectiveStatus::Available => "available", - ExternalSourceEffectiveStatus::Removed => "removed", - }; - lines.push(format!( - "Source {}: {} ({desired}, {effective})", - source.stable_key, source.display_name - )); - } - for capability in &control.capabilities { - let label = match capability.kind { - ExternalCapabilityKindV1::Command => "Commands", - ExternalCapabilityKindV1::Tool => "Tools", - ExternalCapabilityKindV1::Subagent => "Agents", - ExternalCapabilityKindV1::Mcp => "MCP servers", - }; - let runtime = match capability.runtime { - ExternalSourceRuntimeState::NotApplicable => "not applicable", - ExternalSourceRuntimeState::Inactive => "inactive", - ExternalSourceRuntimeState::Starting => "starting", - ExternalSourceRuntimeState::Active => "active", - ExternalSourceRuntimeState::Degraded => "degraded", - ExternalSourceRuntimeState::Quarantined => "quarantined", - ExternalSourceRuntimeState::Unsupported => "unsupported", - }; - let support = match capability.support { - ExternalSourceSupportState::Supported => "", - ExternalSourceSupportState::Partial => ", support: partial", - ExternalSourceSupportState::Unsupported => ", support: unsupported", - ExternalSourceSupportState::Unavailable => ", support: unavailable", - }; - lines.push(format!( - "{label}: {} items, {} review, {} conflicts, {runtime}{support}", - capability.item_count, - capability.pending_review_count, - capability.unresolved_conflict_count, - )); - } const MAX_STATUS_DETAILS: usize = 4; if !control.diagnostics.is_empty() { lines.push(String::new()); - lines.push("Issues".to_string()); + lines.push("Needs attention".to_string()); for diagnostic in control.diagnostics.iter().take(MAX_STATUS_DETAILS) { - let severity = match diagnostic.severity { - ExternalSourceDiagnosticSeverity::Info => "info", - ExternalSourceDiagnosticSeverity::Warning => "warning", - ExternalSourceDiagnosticSeverity::Error => "error", - _ => "notice", - }; lines.push(format!( - " - {severity}: [{}] {}", - diagnostic.code, + " - {}", external_source_diagnostic_summary(&diagnostic.code) )); } let hidden = control.diagnostics.len().saturating_sub(MAX_STATUS_DETAILS); if hidden > 0 { - lines.push(format!( - " - {hidden} more; refresh after fixing the listed issue(s)." - )); + lines.push(format!(" - {hidden} more issue(s).")); } } - if include_mutations && !control.recovery_actions.is_empty() { - lines.push(String::new()); - lines.push("Recovery".to_string()); - for action in control.recovery_actions.iter().take(MAX_STATUS_DETAILS) { - lines.push(format!( - " - {}", - external_recovery_action_label(action, "extensions") - )); + if !control.recovery_actions.is_empty() { + let recovery = control + .recovery_actions + .iter() + .filter(|action| { + !matches!( + action, + ExternalSourceRecoveryActionV1::Review + | ExternalSourceRecoveryActionV1::ExitSafeMode + ) + }) + .take(MAX_STATUS_DETAILS) + .map(|action| external_recovery_action_label(action, "extensions")) + .collect::>(); + if !recovery.is_empty() { + lines.push(String::new()); + lines.push(format!("Next: {}", recovery.join("; "))); } } lines.push(String::new()); - lines.push("Refresh: /extensions refresh".to_string()); - if include_mutations { - lines.push(if control.safe_mode { - "Exit Safe Mode: /extensions safe-mode off".to_string() - } else { - "Enter Safe Mode: /extensions safe-mode on".to_string() - }); - lines.push("Enable source: /extensions source enable ".to_string()); - lines.push("Disable source: /extensions source disable ".to_string()); - } else { - lines.push( - "Read-only compatibility status: upgrade or reconnect the Host to manage applications." - .to_string(), - ); + if control.host_capabilities.can_refresh { + lines.push("Refresh: /extensions refresh".to_string()); } lines.join("\n") } @@ -2469,581 +1701,6 @@ fn external_agent_pending_notice_key( external_agent_attention(previous, snapshot).key } -#[cfg(test)] -mod external_application_v2_tests { - use super::*; - use bitfun_product_domains::external_source_control::{ - ExternalApplicationConnectionStateV2, ExternalApplicationDefaultConnectionPolicyV2, - ExternalApplicationDesiredConnectionV2, ExternalApplicationDiscoveryStateV2, - ExternalApplicationEffectiveStatusV2, ExternalApplicationHealthV2, - ExternalApplicationHostCapabilitiesV2, ExternalApplicationOwnerGenerationV2, - ExternalApplicationPrimaryActionV2, ExternalApplicationReviewCategoryCountV2, - ExternalApplicationReviewItemKindV2, ExternalApplicationReviewItemRefV2, - ExternalApplicationReviewItemV2, ExternalApplicationReviewPageV2, - ExternalApplicationReviewRecommendationSummaryV2, ExternalApplicationReviewSummaryV2, - ExternalApplicationRiskLevelV2, ExternalApplicationRiskSummaryV2, - ExternalApplicationSafetyCeilingV2, ExternalApplicationSnapshotV2, - ExternalApplicationSummaryV2, ExternalApplicationTargetScopeV2, - ExternalApplicationUserDecisionV2, EXTERNAL_APPLICATION_SCHEMA_V2, - }; - use bitfun_product_domains::external_sources::ExecutionDomainId; - - fn risk() -> ExternalApplicationRiskSummaryV2 { - ExternalApplicationRiskSummaryV2 { - highest_level: Some(ExternalApplicationRiskLevelV2::High), - reason_codes: vec!["process_execution".to_string()], - } - } - - fn application( - id: &str, - status: ExternalApplicationEffectiveStatusV2, - action: ExternalApplicationPrimaryActionV2, - ) -> ExternalApplicationSummaryV2 { - ExternalApplicationSummaryV2 { - application_id: id.to_string(), - ecosystem_id: id.to_string(), - display_name: id.to_string(), - discovery: if status == ExternalApplicationEffectiveStatusV2::NoConfiguration { - ExternalApplicationDiscoveryStateV2::NotDiscovered - } else { - ExternalApplicationDiscoveryStateV2::Discovered - }, - connection: if status == ExternalApplicationEffectiveStatusV2::Connected { - ExternalApplicationConnectionStateV2::Connected - } else { - ExternalApplicationConnectionStateV2::Disconnected - }, - desired_connection: ExternalApplicationDesiredConnectionV2::Unspecified, - health: ExternalApplicationHealthV2::Healthy, - effective_status: status, - primary_action: action, - default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2::DiscoverOnly, - default_connection_reason: "product_policy".to_string(), - enabled_count: 1, - pending_review_count: usize::from( - status == ExternalApplicationEffectiveStatusV2::NeedsAttention, - ), - blocked_count: 0, - conflict_count: 0, - risk_summary: risk(), - notice_key: None, - user_decision: ExternalApplicationUserDecisionV2::None, - recovery_actions: Vec::new(), - } - } - - fn snapshot( - capabilities: ExternalApplicationHostCapabilitiesV2, - ) -> ExternalApplicationSnapshotV2 { - ExternalApplicationSnapshotV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: ExecutionDomainId::new("host-a").unwrap(), - workspace_scope_id: Some("workspace:0123456789abcdef".to_string()), - effective_connection_scope: ExternalApplicationTargetScopeV2::WorkspaceOverride, - refresh_generation: 7, - preference_revision: 11, - safe_mode: true, - host_capabilities: capabilities, - applications: vec![ - application( - "connected", - ExternalApplicationEffectiveStatusV2::Connected, - ExternalApplicationPrimaryActionV2::View, - ), - application( - "available", - ExternalApplicationEffectiveStatusV2::ConfigurationAvailable, - ExternalApplicationPrimaryActionV2::Connect, - ), - application( - "missing", - ExternalApplicationEffectiveStatusV2::NoConfiguration, - ExternalApplicationPrimaryActionV2::None, - ), - application( - "attention", - ExternalApplicationEffectiveStatusV2::NeedsAttention, - ExternalApplicationPrimaryActionV2::Review, - ), - application( - "unavailable", - ExternalApplicationEffectiveStatusV2::TemporarilyUnavailable, - ExternalApplicationPrimaryActionV2::Retry, - ), - ], - review_summary: Some(ExternalApplicationReviewSummaryV2 { - review_id: "review-7".to_string(), - total_count: 2, - category_counts: vec![ExternalApplicationReviewCategoryCountV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - count: 2, - }], - max_selection_count: 2, - risk_summary: risk(), - recommendation_summary: ExternalApplicationReviewRecommendationSummaryV2 { - recommended_count: 1, - optional_count: 1, - blocked_count: 0, - }, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - }), - } - } - - fn item(stable_id: &str, recommended: bool) -> ExternalApplicationReviewItemV2 { - ExternalApplicationReviewItemV2 { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: stable_id.to_string(), - }, - display_name: stable_id.to_string(), - display_summary: "Runs an external tool".to_string(), - risk_level: ExternalApplicationRiskLevelV2::High, - risk_reason_codes: vec!["process_execution".to_string()], - recommended, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - } - } - - fn page(cursor: Option<&str>, next_cursor: Option<&str>) -> ExternalApplicationReviewPageV2 { - ExternalApplicationReviewPageV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: ExecutionDomainId::new("host-a").unwrap(), - workspace_scope_id: Some("workspace:0123456789abcdef".to_string()), - target_scope: ExternalApplicationTargetScopeV2::WorkspaceOverride, - review_id: "review-7".to_string(), - preference_revision: 11, - expected_generations: vec![ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Tool, - generation: 7, - }], - cursor: cursor.map(str::to_string), - next_cursor: next_cursor.map(str::to_string), - total_count: 2, - items: vec![item("tool-recommended", true), item("tool-optional", false)], - } - } - - #[test] - fn overview_uses_five_shared_states_and_hides_mutations_for_read_only_hosts() { - let writable = external_application_overview_text(&snapshot( - ExternalApplicationHostCapabilitiesV2::read_write(), - )); - for expected in [ - "Connected", - "Configuration available", - "No configuration", - "Needs attention", - "Temporarily unavailable", - ] { - assert!(writable.contains(expected), "{expected}\n{writable}"); - } - assert!(writable.contains("/extensions connect 2")); - assert!(writable.contains("/extensions review")); - assert!(writable.contains("Safe Mode: on")); - assert!(!writable.contains("Health: healthy"), "{writable}"); - assert!(!writable.contains(" enabled,"), "{writable}"); - assert!( - !writable.contains("Refresh: /extensions refresh"), - "{writable}" - ); - - let read_only = external_application_overview_text(&snapshot( - ExternalApplicationHostCapabilitiesV2::read_only(), - )); - for forbidden in [ - "/extensions connect", - "/extensions disconnect", - "/extensions defer", - "/extensions review allow", - "/extensions review deny", - ] { - assert!(!read_only.contains(forbidden), "{forbidden}\n{read_only}"); - } - } - - #[test] - fn legacy_status_fallback_is_embedded_read_only_only() { - for code in [ - ExternalSourceOperationErrorCode::HostCapabilityUnavailable, - ExternalSourceOperationErrorCode::Unsupported, - ] { - let error = ExternalSourceOperationError::new(code, "V2 unavailable", false); - assert!(should_fallback_to_legacy_external_status(false, &error)); - assert!(!should_fallback_to_legacy_external_status(true, &error)); - } - let unrelated = ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::Internal, - "Host failed", - false, - ); - assert!(!should_fallback_to_legacy_external_status( - false, &unrelated - )); - } - - #[test] - fn overview_preserves_host_health_and_recovery_without_recomputing_status() { - let mut host = snapshot(ExternalApplicationHostCapabilitiesV2::read_write()); - host.applications[0].health = ExternalApplicationHealthV2::Degraded; - host.applications[0].recovery_actions = vec![ - bitfun_product_domains::external_source_control::ExternalApplicationRecoveryActionV2::ReconnectHost, - bitfun_product_domains::external_source_control::ExternalApplicationRecoveryActionV2::ViewReason, - ]; - - let text = external_application_overview_text(&host); - assert!(text.contains("connected - Connected")); - assert!(text.contains("Health: degraded")); - assert!(text.contains("Recovery: reconnect host, view reason")); - } - - #[test] - fn numbered_application_actions_require_the_rendered_v2_snapshot() { - let unavailable = ExternalApplicationUiState::default(); - assert!(parse_external_application_action("connect 1", &unavailable) - .unwrap_err() - .contains("V2")); - - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_write())) - .unwrap(); - assert_eq!( - parse_external_application_action("connect 2", &state).unwrap(), - ExternalApplicationUiAction::ConnectApplication { - application_id: "available".to_string() - } - ); - assert_eq!( - parse_external_application_action("disconnect 1", &state).unwrap(), - ExternalApplicationUiAction::DisconnectApplication { - application_id: "connected".to_string() - } - ); - assert_eq!( - parse_external_application_action("defer 4", &state).unwrap(), - ExternalApplicationUiAction::DeferApplication { - application_id: "attention".to_string() - } - ); - assert!(parse_external_application_action("connect 1", &state) - .unwrap_err() - .contains("next action")); - assert!(parse_external_application_action("disconnect 2", &state) - .unwrap_err() - .contains("next action")); - assert!(parse_external_application_action("defer 2", &state) - .unwrap_err() - .contains("next action")); - - let read_only = { - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_only())) - .unwrap(); - state - }; - assert!(parse_external_application_action("connect 2", &read_only) - .unwrap_err() - .contains("read-only")); - } - - #[test] - fn workspace_context_targets_an_override_even_when_the_effective_value_is_inherited() { - let mut inherited = snapshot(ExternalApplicationHostCapabilitiesV2::read_write()); - inherited.effective_connection_scope = ExternalApplicationTargetScopeV2::UserDefault; - let mut state = ExternalApplicationUiState::default(); - state.replace_snapshot(inherited).unwrap(); - - let request = state - .control_request( - "operation-workspace", - ExternalApplicationControlActionV2::ConnectApplication { - application_id: "available".to_string(), - }, - ) - .unwrap(); - assert_eq!( - request.target_scope, - ExternalApplicationTargetScopeV2::WorkspaceOverride - ); - assert_eq!( - request.workspace_scope_id.as_deref(), - Some("workspace:0123456789abcdef") - ); - - let mut user_default = snapshot(ExternalApplicationHostCapabilitiesV2::read_write()); - user_default.workspace_scope_id = None; - user_default.effective_connection_scope = ExternalApplicationTargetScopeV2::UserDefault; - state.replace_snapshot(user_default).unwrap(); - let request = state - .control_request( - "operation-user", - ExternalApplicationControlActionV2::ConnectApplication { - application_id: "available".to_string(), - }, - ) - .unwrap(); - assert_eq!( - request.target_scope, - ExternalApplicationTargetScopeV2::UserDefault - ); - assert_eq!(request.workspace_scope_id, None); - } - - #[test] - fn review_selection_stores_only_overrides_to_the_recommended_baseline() { - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_write())) - .unwrap(); - state - .replace_review_page(page(None, Some("page-2")), ExternalReviewNavigation::Open) - .unwrap(); - - assert!(state.review_item_selected(0).unwrap()); - assert!(!state.review_item_selected(1).unwrap()); - state.set_review_item_selected(0, false).unwrap(); - state.set_review_item_selected(1, true).unwrap(); - - let request = state - .review_submit_request( - "operation-1", - ExternalApplicationReviewSelectionBaselineV2::Recommended, - None, - ) - .unwrap(); - let bitfun_product_domains::external_source_control::ExternalApplicationControlActionV2::SubmitApplicationReview { - selection_baseline, - selection_overrides, - .. - } = request.action else { - panic!("expected review action"); - }; - assert_eq!( - selection_baseline, - bitfun_product_domains::external_source_control::ExternalApplicationReviewSelectionBaselineV2::Recommended - ); - assert_eq!(selection_overrides.len(), 2); - assert!(!selection_overrides[0].selected); - assert!(selection_overrides[1].selected); - - let deny_request = state - .review_submit_request( - "operation-deny", - ExternalApplicationReviewSelectionBaselineV2::None, - None, - ) - .unwrap(); - let ExternalApplicationControlActionV2::SubmitApplicationReview { - selection_baseline, - selection_overrides, - .. - } = deny_request.action - else { - panic!("expected review action"); - }; - assert_eq!( - selection_baseline, - ExternalApplicationReviewSelectionBaselineV2::None - ); - assert!(selection_overrides.is_empty()); - } - - #[test] - fn review_commands_keep_single_decisions_direct_and_batch_application_explicit() { - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_write())) - .unwrap(); - state - .replace_review_page(page(None, None), ExternalReviewNavigation::Open) - .unwrap(); - - assert!(parse_external_application_action("review allow", &state).is_err()); - assert_eq!( - parse_external_application_action("review apply", &state).unwrap(), - ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, - immediate_selection: None, - } - ); - assert_eq!( - parse_external_application_action("review deny", &state).unwrap(), - ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::None, - immediate_selection: None, - } - ); - - let text = external_application_review_text(&state).unwrap(); - assert!(text.contains("Apply selections: /extensions review apply")); - assert!(text.contains("Keep all disabled: /extensions review deny")); - assert!(!text.contains("Runs an external tool")); - assert!(!text.contains("Baseline:")); - assert!(!text.contains("review defer")); - - let mut direct = page(None, None); - direct.total_count = 1; - direct.items = vec![item("tool-optional", false)]; - state - .replace_review_page(direct, ExternalReviewNavigation::Open) - .unwrap(); - let direct_action = parse_external_application_action("review allow", &state).unwrap(); - assert_eq!( - direct_action, - ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, - immediate_selection: Some(( - ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: "tool-optional".to_string(), - }, - true, - )), - } - ); - let ExternalApplicationUiAction::SubmitReview { - baseline, - immediate_selection, - } = direct_action - else { - panic!("expected direct review submission"); - }; - let request = state - .review_submit_request( - "operation-direct", - baseline, - immediate_selection - .as_ref() - .map(|(item_ref, selected)| (item_ref, *selected)), - ) - .unwrap(); - let ExternalApplicationControlActionV2::SubmitApplicationReview { - selection_overrides, - .. - } = request.action - else { - panic!("expected review action"); - }; - assert_eq!(selection_overrides.len(), 1); - assert_eq!(selection_overrides[0].item_ref.stable_id, "tool-optional"); - assert!(selection_overrides[0].selected); - let direct_text = external_application_review_text(&state).unwrap(); - assert!(direct_text.contains("Enable: /extensions review allow")); - assert!(direct_text.contains("Keep disabled: /extensions review deny")); - } - - #[test] - fn review_navigation_binds_cursors_and_rejects_stale_pages() { - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_write())) - .unwrap(); - state - .replace_review_page(page(None, Some("page-2")), ExternalReviewNavigation::Open) - .unwrap(); - - let (next, navigation) = state - .review_page_request(ExternalReviewDirection::Next) - .unwrap(); - assert_eq!(next.cursor.as_deref(), Some("page-2")); - state - .replace_review_page(page(Some("page-2"), None), navigation) - .unwrap(); - let (previous, navigation) = state - .review_page_request(ExternalReviewDirection::Previous) - .unwrap(); - assert_eq!(previous.cursor, None); - state - .replace_review_page(page(None, Some("page-2")), navigation) - .unwrap(); - - let mut stale = page(None, None); - stale.preference_revision += 1; - assert!(state - .replace_review_page(stale, ExternalReviewNavigation::Open) - .unwrap_err() - .contains("stale")); - - let (next, navigation) = state - .review_page_request(ExternalReviewDirection::Next) - .unwrap(); - let mut stale_generation = page(next.cursor.as_deref(), None); - stale_generation.expected_generations[0].generation += 1; - assert!(state - .replace_review_page(stale_generation, navigation) - .unwrap_err() - .contains("stale")); - } - - #[test] - fn opening_review_accepts_the_hosts_current_read_only_plan() { - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_write())) - .unwrap(); - let mut current = page(None, None); - current.review_id = "review-current".to_string(); - current.expected_generations[0].generation += 1; - - state - .replace_review_page(current, ExternalReviewNavigation::Open) - .unwrap(); - assert_eq!( - state.review.as_ref().unwrap().page.review_id, - "review-current" - ); - } - - #[test] - fn review_commands_resolve_current_page_numbers_and_batch_decisions() { - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_write())) - .unwrap(); - state - .replace_review_page(page(None, None), ExternalReviewNavigation::Open) - .unwrap(); - - assert_eq!( - parse_external_application_action("review include 2", &state).unwrap(), - ExternalApplicationUiAction::SetReviewItem { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: "tool-optional".to_string(), - }, - selected: true, - } - ); - assert_eq!( - parse_external_application_action("review exclude 1", &state).unwrap(), - ExternalApplicationUiAction::SetReviewItem { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: "tool-recommended".to_string(), - }, - selected: false, - } - ); - assert_eq!( - parse_external_application_action("review apply", &state).unwrap(), - ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, - immediate_selection: None, - } - ); - assert_eq!( - parse_external_application_action("review deny", &state).unwrap(), - ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::None, - immediate_selection: None, - } - ); - } -} - fn parse_external_agent_review_action( arguments: &str, current_snapshot: Option<&ExternalSourceCatalogSnapshot>, diff --git a/src/apps/cli/src/modes/chat/external_sources.rs b/src/apps/cli/src/modes/chat/external_sources.rs index 385f0bb45..b98017f2f 100644 --- a/src/apps/cli/src/modes/chat/external_sources.rs +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -376,267 +376,6 @@ impl ChatMode { } fn handle_external_control( - &mut self, - arguments: &str, - chat_view: &mut ChatView, - chat_state: &ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let legacy_command = arguments.split_whitespace().next().is_some_and(|command| { - command.eq_ignore_ascii_case("safe-mode") || command.eq_ignore_ascii_case("source") - }); - if !legacy_command { - self.handle_external_application(arguments, chat_view, rt_handle); - return; - } - self.handle_legacy_external_control(arguments, chat_view, chat_state, rt_handle); - } - - fn handle_external_application( - &mut self, - arguments: &str, - chat_view: &mut ChatView, - rt_handle: &tokio::runtime::Handle, - ) { - let action = - match parse_external_application_action(arguments, &self.external_application_ui) { - Ok(action) => action, - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - }; - if self.external_application_ui.pending_rx.is_some() { - chat_view.set_status(Some( - "An external application update is already running; input remains available." - .to_string(), - )); - return; - } - - if let ExternalApplicationUiAction::SetReviewItem { item_ref, selected } = &action { - let index = self - .external_application_ui - .review - .as_ref() - .and_then(|review| { - review - .page - .items - .iter() - .position(|item| item.item_ref == *item_ref) - }); - let result = index - .ok_or_else(|| "That review item is stale; reopen /extensions review.".to_string()) - .and_then(|index| { - self.external_application_ui - .set_review_item_selected(index, *selected) - }); - match result - .and_then(|()| external_application_review_text(&self.external_application_ui)) - { - Ok(text) => { - chat_view.show_info_popup(text); - chat_view.set_status(Some( - "Review selection updated; run /extensions review apply to use it." - .to_string(), - )); - } - Err(error) => chat_view.set_status(Some(error)), - } - return; - } - let pending = match &action { - ExternalApplicationUiAction::Show => ExternalApplicationPendingRequest::Snapshot { - force_refresh: false, - }, - ExternalApplicationUiAction::Refresh => ExternalApplicationPendingRequest::Snapshot { - force_refresh: true, - }, - ExternalApplicationUiAction::OpenReview => { - let request = match self.external_application_ui.open_review_page_request() { - Ok(request) => request, - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - }; - ExternalApplicationPendingRequest::ReviewPage { - request, - navigation: ExternalReviewNavigation::Open, - } - } - ExternalApplicationUiAction::ReviewNext => { - let (request, navigation) = match self - .external_application_ui - .review_page_request(ExternalReviewDirection::Next) - { - Ok(request) => request, - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - }; - ExternalApplicationPendingRequest::ReviewPage { - request, - navigation, - } - } - ExternalApplicationUiAction::ReviewPrevious => { - let (request, navigation) = match self - .external_application_ui - .review_page_request(ExternalReviewDirection::Previous) - { - Ok(request) => request, - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - }; - ExternalApplicationPendingRequest::ReviewPage { - request, - navigation, - } - } - ExternalApplicationUiAction::ConnectApplication { application_id } => { - let request = self.external_application_ui.control_request( - &format!("tui-{}", uuid::Uuid::new_v4()), - ExternalApplicationControlActionV2::ConnectApplication { - application_id: application_id.clone(), - }, - ); - match request { - Ok(request) => ExternalApplicationPendingRequest::Mutation(request), - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - } - } - ExternalApplicationUiAction::DisconnectApplication { application_id } => { - let request = self.external_application_ui.control_request( - &format!("tui-{}", uuid::Uuid::new_v4()), - ExternalApplicationControlActionV2::DisconnectApplication { - application_id: application_id.clone(), - }, - ); - match request { - Ok(request) => ExternalApplicationPendingRequest::Mutation(request), - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - } - } - ExternalApplicationUiAction::DeferApplication { application_id } => { - let request = self.external_application_ui.control_request( - &format!("tui-{}", uuid::Uuid::new_v4()), - ExternalApplicationControlActionV2::SetApplicationDeferred { - application_id: application_id.clone(), - }, - ); - match request { - Ok(request) => ExternalApplicationPendingRequest::Mutation(request), - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - } - } - ExternalApplicationUiAction::SubmitReview { - baseline, - immediate_selection, - } => { - match self.external_application_ui.review_submit_request( - &format!("tui-{}", uuid::Uuid::new_v4()), - *baseline, - immediate_selection - .as_ref() - .map(|(item_ref, selected)| (item_ref, *selected)), - ) { - Ok(request) => ExternalApplicationPendingRequest::Mutation(request), - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - } - } - ExternalApplicationUiAction::SetReviewItem { .. } => unreachable!(), - }; - - let agent = self.agent.clone(); - let shared = agent.is_shared(); - let task_action = action.clone(); - let (sender, receiver) = mpsc::channel(); - rt_handle.spawn(async move { - let result = match pending { - ExternalApplicationPendingRequest::Snapshot { force_refresh } => { - match agent.external_application_snapshot_v2(force_refresh).await { - Ok(snapshot) => Ok(ExternalApplicationAsyncResult::Snapshot(snapshot)), - Err(error) if should_fallback_to_legacy_external_status(shared, &error) => { - agent - .external_source_snapshot(force_refresh) - .await - .map(ExternalApplicationAsyncResult::LegacySnapshot) - } - Err(error) => Err(error), - } - } - ExternalApplicationPendingRequest::ReviewPage { - request, - navigation, - } => agent - .external_application_review_page_v2(request) - .await - .map(|page| ExternalApplicationAsyncResult::ReviewPage { page, navigation }), - ExternalApplicationPendingRequest::Mutation(request) => { - let result = agent.apply_external_application_action_v2(request).await; - match result { - Ok(result) => { - agent - .external_application_snapshot_v2(false) - .await - .map(|snapshot| ExternalApplicationAsyncResult::Mutation { - result, - snapshot, - }) - } - Err(error) => Err(error), - } - } - }; - let _ = sender.send(ExternalApplicationMutationResult { - action: task_action, - result, - }); - }); - self.external_application_ui.pending_rx = Some(receiver); - let status = match action { - ExternalApplicationUiAction::Show => "Reading external applications", - ExternalApplicationUiAction::Refresh => "Refreshing external applications", - ExternalApplicationUiAction::OpenReview - | ExternalApplicationUiAction::ReviewNext - | ExternalApplicationUiAction::ReviewPrevious => "Reading external application review", - ExternalApplicationUiAction::ConnectApplication { .. } => { - "Connecting external application" - } - ExternalApplicationUiAction::DisconnectApplication { .. } => { - "Disconnecting external application" - } - ExternalApplicationUiAction::DeferApplication { .. } => { - "Deferring external application decision" - } - ExternalApplicationUiAction::SubmitReview { .. } => { - "Applying external application review" - } - ExternalApplicationUiAction::SetReviewItem { .. } => unreachable!(), - }; - chat_view.set_status(Some(format!( - "{status}; you can continue typing or cancel other UI work" - ))); - } - - fn handle_legacy_external_control( &mut self, arguments: &str, chat_view: &mut ChatView, @@ -652,14 +391,40 @@ impl ChatMode { }; if self.external_control_mutation_rx.is_some() { chat_view.set_status(Some( - "An external integration update is already running; input remains available." - .to_string(), + "An extension update is already running; input remains available.".to_string(), )); return; } + let source_selection = match &action { + ExternalControlUiAction::SetSourceEnabled { + source_index, + enabled, + } => { + let Some(control) = self.external_control_snapshot.as_ref() else { + chat_view.set_status(Some( + "Open /extensions before changing an extension.".to_string(), + )); + return; + }; + if !control.host_capabilities.can_manage_sources { + chat_view.set_status(Some( + "This connection can only show extension status.".to_string(), + )); + return; + } + let Some(source) = control.sources.get(*source_index) else { + chat_view.set_status(Some( + "That extension is no longer listed. Run /extensions refresh.".to_string(), + )); + return; + }; + Some((source.stable_key.clone(), *enabled)) + } + _ => None, + }; let expected_preference_revision = self - .external_source_snapshot + .external_control_snapshot .as_ref() .map(|snapshot| snapshot.preference_revision); let task_action = action.clone(); @@ -681,13 +446,15 @@ impl ChatMode { ExternalControlUiAction::SetSafeMode(enabled) => { ExternalSourceControlActionV1::SetSafeMode { enabled: *enabled } } - ExternalControlUiAction::SetSourceEnabled { - source_key, - enabled, - } => ExternalSourceControlActionV1::SetSourceEnabled { - source_key: source_key.clone(), - enabled: *enabled, - }, + ExternalControlUiAction::SetSourceEnabled { .. } => { + let (source_key, enabled) = source_selection + .clone() + .expect("source selection was resolved before spawning"); + ExternalSourceControlActionV1::SetSourceEnabled { + source_key, + enabled, + } + } ExternalControlUiAction::Show => unreachable!(), }; let response = agent @@ -712,15 +479,13 @@ impl ChatMode { }); self.external_control_mutation_rx = Some(receiver); let status = match action { - ExternalControlUiAction::Show => "Reading external integration status", - ExternalControlUiAction::Refresh => "Refreshing external integrations", - ExternalControlUiAction::SetSafeMode(true) => "Entering External Safe Mode", - ExternalControlUiAction::SetSafeMode(false) => "Exiting External Safe Mode", - ExternalControlUiAction::SetSourceEnabled { enabled: true, .. } => { - "Enabling external source" - } + ExternalControlUiAction::Show => "Reading extension status", + ExternalControlUiAction::Refresh => "Refreshing extensions", + ExternalControlUiAction::SetSafeMode(true) => "Pausing external access", + ExternalControlUiAction::SetSafeMode(false) => "Resuming external access", + ExternalControlUiAction::SetSourceEnabled { enabled: true, .. } => "Enabling extension", ExternalControlUiAction::SetSourceEnabled { enabled: false, .. } => { - "Disabling external source" + "Disabling extension" } }; chat_view.set_status(Some(format!( @@ -728,161 +493,14 @@ impl ChatMode { ))); } - fn poll_external_application_mutation(&mut self, chat_view: &mut ChatView) -> bool { - let outcome = match self - .external_application_ui - .pending_rx - .as_ref() - .map(Receiver::try_recv) - { - Some(Ok(outcome)) => outcome, - Some(Err(MpscTryRecvError::Empty)) | None => return false, - Some(Err(MpscTryRecvError::Disconnected)) => { - self.external_application_ui.pending_rx = None; - chat_view.set_status(Some( - "External application status stopped before returning a result; retry /extensions status." - .to_string(), - )); - return true; - } - }; - self.external_application_ui.pending_rx = None; - match outcome.result { - Ok(ExternalApplicationAsyncResult::Snapshot(snapshot)) => { - match self.external_application_ui.replace_snapshot(snapshot) { - Ok(()) => { - if let Ok(snapshot) = self.external_application_ui.snapshot() { - chat_view.show_info_popup(external_application_overview_text(snapshot)); - chat_view.set_status(Some( - if matches!(outcome.action, ExternalApplicationUiAction::Refresh) { - "External applications refreshed" - } else { - "External application status updated" - } - .to_string(), - )); - } - } - Err(error) => chat_view.set_status(Some(error)), - } - } - Ok(ExternalApplicationAsyncResult::LegacySnapshot(response)) => { - self.replace_external_conflict_preferences(response.preferences.into()); - self.update_external_source_view(chat_view, &response.snapshot); - self.external_source_snapshot = Some(response.snapshot); - self.external_application_ui.snapshot = None; - self.external_application_ui.review = None; - chat_view - .show_info_popup(external_control_read_only_review_text(&response.control)); - chat_view.set_status(Some( - "External application status is shown in read-only compatibility mode" - .to_string(), - )); - } - Ok(ExternalApplicationAsyncResult::ReviewPage { page, navigation }) => { - match self - .external_application_ui - .replace_review_page(page, navigation) - .and_then(|()| external_application_review_text(&self.external_application_ui)) - { - Ok(text) => { - chat_view.show_info_popup(text); - chat_view.set_status(Some( - "External application review page updated".to_string(), - )); - } - Err(error) => { - self.external_application_ui.review = None; - chat_view.set_status(Some(error)); - } - } - } - Ok(ExternalApplicationAsyncResult::Mutation { result, snapshot }) => { - if let Err(error) = result.validate() { - chat_view.set_status(Some(format!( - "The external application response was invalid: {error}" - ))); - return true; - } - let operation_outcome = result.outcome; - let partial = result - .item_results - .iter() - .any(|item| item.outcome != ExternalApplicationOperationOutcomeV2::Applied); - if let Err(error) = self.external_application_ui.replace_snapshot(snapshot) { - chat_view.set_status(Some(error)); - return true; - } - if let Ok(snapshot) = self.external_application_ui.snapshot() { - chat_view.show_info_popup(external_application_overview_text(snapshot)); - } - let status = match operation_outcome { - ExternalApplicationOperationOutcomeV2::Applied if partial => { - "External application changes were partially applied; review the refreshed status" - } - ExternalApplicationOperationOutcomeV2::Applied => match outcome.action { - ExternalApplicationUiAction::ConnectApplication { .. } => { - "External application connected" - } - ExternalApplicationUiAction::DisconnectApplication { .. } => { - "External application disconnected" - } - ExternalApplicationUiAction::DeferApplication { .. } => { - "External application decision deferred" - } - ExternalApplicationUiAction::SubmitReview { .. } => { - "External application review applied" - } - _ => "External application change applied", - }, - ExternalApplicationOperationOutcomeV2::Stale => { - "Nothing was applied because the external application data changed; review the refreshed status" - } - ExternalApplicationOperationOutcomeV2::Blocked => { - "External application change was blocked; review the refreshed status" - } - ExternalApplicationOperationOutcomeV2::Rejected => { - "External application change was rejected; review the refreshed status" - } - ExternalApplicationOperationOutcomeV2::Failed => { - "External application change failed; review the refreshed status" - } - }; - chat_view.set_status(Some(status.to_string())); - } - Err(error) => { - if matches!( - error.code, - ExternalSourceOperationErrorCode::HostCapabilityUnavailable - | ExternalSourceOperationErrorCode::Unsupported - | ExternalSourceOperationErrorCode::IncompatibleVersion - ) { - self.external_application_ui.snapshot = None; - self.external_application_ui.review = None; - } else if matches!(error.code, ExternalSourceOperationErrorCode::StaleRevision) { - self.external_application_ui.review = None; - } - tracing::warn!( - error_code = error.code.as_str(), - correlation_id = error.correlation_id.as_deref().unwrap_or("none"), - operation_stage = ?error.stage, - "External application action failed" - ); - chat_view.set_status(Some(external_operation_error_status("extensions", &error))); - } - } - true - } - fn poll_external_control_mutation(&mut self, chat_view: &mut ChatView) -> bool { - let application_changed = self.poll_external_application_mutation(chat_view); let outcome = match self .external_control_mutation_rx .as_ref() .map(Receiver::try_recv) { Some(Ok(outcome)) => outcome, - Some(Err(MpscTryRecvError::Empty)) | None => return application_changed, + Some(Err(MpscTryRecvError::Empty)) | None => return false, Some(Err(MpscTryRecvError::Disconnected)) => { self.external_control_mutation_rx = None; chat_view.set_status(Some( @@ -902,19 +520,18 @@ impl ChatMode { self.update_external_source_view(chat_view, &catalog); self.external_source_snapshot = Some(catalog); } - chat_view.show_info_popup(external_control_review_text(&control)); + chat_view.show_info_popup(external_control_status_text(&control)); + self.external_control_snapshot = Some(control); let status = match outcome.action { - ExternalControlUiAction::Show => "External integration status updated", - ExternalControlUiAction::Refresh => "External integrations refreshed", - ExternalControlUiAction::SetSafeMode(true) => "External Safe Mode is active", - ExternalControlUiAction::SetSafeMode(false) => { - "External Safe Mode is off; eligible integrations were reconciled" - } + ExternalControlUiAction::Show => "Extension status updated", + ExternalControlUiAction::Refresh => "Extensions refreshed", + ExternalControlUiAction::SetSafeMode(true) => "External access is paused", + ExternalControlUiAction::SetSafeMode(false) => "External access resumed", ExternalControlUiAction::SetSourceEnabled { enabled: true, .. } => { - "External source enabled" + "Extension enabled" } ExternalControlUiAction::SetSourceEnabled { enabled: false, .. } => { - "External source disabled" + "Extension disabled" } }; chat_view.set_status(Some(status.to_string())); diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 3e6e4f0d0..966650120 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -11,8 +11,7 @@ mod tests { command_route, consume_selected_native_command_once, context_compression_tool_event, extension_command_help_request, external_agent_attention, external_agent_diagnostic_lines, external_agent_pending_notice_key, external_agent_result_is_stale, - external_agent_review_text, external_command_projections, - external_control_read_only_review_text, external_control_review_text, + external_agent_review_text, external_command_projections, external_control_status_text, external_hook_help_text, external_integration_policy_lines, external_operation_error_status, external_tool_mutation_result_label, external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text, @@ -164,21 +163,25 @@ mod tests { ExternalControlUiAction::SetSafeMode(false) ); assert_eq!( - parse_external_control_action("source disable opencode.commands:project").unwrap(), + parse_external_control_action("disable 1").unwrap(), ExternalControlUiAction::SetSourceEnabled { - source_key: "opencode.commands:project".to_string(), + source_index: 0, enabled: false, } ); assert_eq!( - parse_external_control_action("source enable opencode.commands:project").unwrap(), + parse_external_control_action("enable 2").unwrap(), ExternalControlUiAction::SetSourceEnabled { - source_key: "opencode.commands:project".to_string(), + source_index: 1, enabled: true, } ); assert!(parse_external_control_action("safe-mode toggle").is_err()); assert!(parse_external_control_action("enable-everything").is_err()); + assert!(parse_external_control_action("review").is_err()); + let usage = parse_external_control_action("unknown").unwrap_err(); + assert!(!usage.contains("safe-mode")); + assert!(!usage.contains("review")); } #[test] @@ -224,23 +227,36 @@ mod tests { })) .unwrap(); - let text = external_control_review_text(&control); - assert!(text.contains("Safe Mode: on")); - assert!(text.contains("Generation: 9")); - assert!(text.contains("Execution domain: local-user")); - assert!(text.contains("New external Tool, Agent, and MCP calls are blocked")); - assert!(text.contains("restarting the Host turns it off")); - assert!(text.contains("Source opencode.commands:project")); - assert!(text.contains("source disable ")); - assert!(text.contains("Tools: 2 items, 1 review, 0 conflicts, inactive")); - assert!(text.contains("/extensions safe-mode off")); + let text = external_control_status_text(&control); + assert!(text.contains("Extensions")); + assert!(text.contains("1. OpenCode project commands - Available")); + assert!(text.contains("Disable: /extensions disable 1")); + assert!(text.contains("Refresh: /extensions refresh")); + assert!(text.contains("External access is paused. Resume: /extensions safe-mode off")); + for hidden in [ + "Generation", + "Execution domain", + "opencode.commands:project", + "review", + "items", + "conflicts", + "", + ] { + assert!(!text.contains(hidden), "leaked {hidden}:\n{text}"); + } + + let mut read_only = control.clone(); + read_only.host_capabilities.can_manage_sources = false; + let read_only_text = external_control_status_text(&read_only); + assert!(read_only_text.contains("This connection can only show extension status.")); + assert!(!read_only_text.contains("/extensions disable 1")); - let read_only = external_control_read_only_review_text(&control); - assert!(read_only.contains("Read-only compatibility status")); - assert!(read_only.contains("/extensions refresh")); - assert!(!read_only.contains("/extensions safe-mode")); - assert!(!read_only.contains("source enable ")); - assert!(!read_only.contains("source disable ")); + let mut permission_needed = control.clone(); + permission_needed.sources[0].effective_status = + bitfun_product_domains::external_source_control::ExternalSourceEffectiveStatus::ReviewRequired; + let permission_text = external_control_status_text(&permission_needed); + assert!(permission_text.contains("Needs permission")); + assert!(permission_text.contains("Manage permissions: /tools, /agent, /mcp, or /hooks")); } #[test] @@ -282,11 +298,11 @@ mod tests { })) .unwrap(); - let text = external_control_review_text(&control); - assert!(text.contains("Tools: 0 items, 0 review, 0 conflicts, inactive, support: partial")); - assert!(text.contains("Issues")); - assert!(text.contains("[external_tool.runtime_unavailable]")); - assert!(text.contains("Recovery")); + let text = external_control_status_text(&control); + assert!(text.contains("No extensions found.")); + assert!(!text.contains("External access is paused")); + assert!(text.contains("Needs attention")); + assert!(!text.contains("external_tool.runtime_unavailable")); assert!(text.contains("/extensions refresh")); assert!(text.contains("install or repair the required runtime")); } diff --git a/src/apps/cli/src/peer_host/commands/external_sources.rs b/src/apps/cli/src/peer_host/commands/external_sources.rs index fb0f42218..07c9ff545 100644 --- a/src/apps/cli/src/peer_host/commands/external_sources.rs +++ b/src/apps/cli/src/peer_host/commands/external_sources.rs @@ -3,9 +3,8 @@ use std::path::PathBuf; use bitfun_core::external_sources::{ - apply_external_application_action_v2, apply_external_source_control_action, - choose_external_mcp_conflict, choose_external_subagent_conflict, external_source_snapshot, - get_external_application_review_page_v2, get_external_application_snapshot_v2, + apply_external_source_control_action, choose_external_mcp_conflict, + choose_external_subagent_conflict, external_source_snapshot, get_external_source_control_snapshot, set_external_mcp_server_decision, set_external_prompt_command_conflict_choice, set_external_source_enabled, set_external_subagent_activation, set_external_subagent_model_binding, @@ -15,38 +14,11 @@ use bitfun_core::external_sources::{ ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, ExternalSubagentModelBindingTarget, }; -use bitfun_product_domains::external_source_control::{ - ExternalApplicationControlRequestV2, ExternalApplicationHostCapabilitiesV2, - ExternalApplicationReviewPageRequestV2, -}; use serde_json::Value; use crate::peer_host::args::request_value; use crate::peer_host::state::PeerHostState; -pub(super) fn supports(command: &str) -> bool { - matches!( - command, - "get_external_source_snapshot" - | "get_external_source_control_snapshot" - | "reveal_external_source_location" - | "apply_external_source_control_action_command" - | "set_external_source_enabled_command" - | "set_external_source_conflict_choice_command" - | "set_external_tool_target_decision_command" - | "set_external_tool_conflict_choice_command" - | "set_external_subagent_activation_command" - | "set_external_subagent_model_binding_command" - | "choose_external_subagent_conflict_command" - | "set_external_mcp_server_decision_command" - | "choose_external_mcp_conflict_command" - | "update_external_integration_policy_command" - | "get_external_application_snapshot_v2" - | "get_external_application_review_page_v2" - | "apply_external_application_action_v2" - ) -} - fn required_bool(request: &Value, key: &str) -> ExternalSourceOperationResult { optional_bool_field(request, key)?.ok_or_else(|| { ExternalSourceOperationError::invalid_request(format!("Missing or invalid '{key}'")) @@ -161,32 +133,6 @@ fn public_snapshot( }) } -fn application_response(response: impl serde::Serialize) -> ExternalSourceOperationResult { - serde_json::to_value(response).map_err(|_| { - ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::Internal, - "External application response could not be encoded", - false, - ) - }) -} - -fn domain_request( - request: &Value, -) -> ExternalSourceOperationResult { - request - .get("request") - .cloned() - .ok_or_else(|| ExternalSourceOperationError::invalid_request("Missing request")) - .and_then(|request| { - serde_json::from_value(request).map_err(|_| { - ExternalSourceOperationError::invalid_request( - "Invalid external application request", - ) - }) - }) -} - pub(crate) async fn dispatch( command: &str, args: &Value, @@ -210,36 +156,6 @@ async fn dispatch_inner( let request = request_value(args); let workspace = workspace_root(state, request).await?; let workspace = workspace.as_deref(); - if command == "get_external_application_snapshot_v2" { - let snapshot = get_external_application_snapshot_v2( - workspace, - optional_bool_field(request, "forceRefresh")?.unwrap_or(false), - ExternalApplicationHostCapabilitiesV2::read_write(), - ) - .await - .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error)?; - return application_response(snapshot); - } - if command == "get_external_application_review_page_v2" { - let page_request: ExternalApplicationReviewPageRequestV2 = domain_request(request)?; - page_request - .validate() - .map_err(ExternalSourceOperationError::invalid_request)?; - let page = get_external_application_review_page_v2(workspace, page_request) - .await - .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error)?; - return application_response(page); - } - if command == "apply_external_application_action_v2" { - let action_request: ExternalApplicationControlRequestV2 = domain_request(request)?; - action_request - .validate() - .map_err(ExternalSourceOperationError::invalid_request)?; - let result = apply_external_application_action_v2(workspace, action_request) - .await - .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error)?; - return application_response(result); - } if command == "get_external_source_control_snapshot" { let snapshot = get_external_source_control_snapshot( workspace, @@ -403,19 +319,6 @@ mod tests { use super::*; use bitfun_core::external_sources::ExternalSourceControlActionV1; - #[test] - fn peer_external_source_router_recognizes_v1_and_v2_without_catching_unrelated_commands() { - for command in [ - "get_external_source_snapshot", - "get_external_application_snapshot_v2", - "get_external_application_review_page_v2", - "apply_external_application_action_v2", - ] { - assert!(supports(command), "missing {command}"); - } - assert!(!supports("get_config")); - } - #[test] fn optional_host_fields_reject_wrong_types() { let request = serde_json::json!({ diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 4921847a4..56a103d09 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -39,7 +39,20 @@ pub(crate) async fn dispatch( "set_config" => config::set_config(args).await, "get_agent_profile_config" => config::get_agent_profile_config(args).await, "get_agent_profile_configs" => config::get_agent_profile_configs().await, - command if external_sources::supports(command) => { + "get_external_source_snapshot" + | "get_external_source_control_snapshot" + | "reveal_external_source_location" + | "apply_external_source_control_action_command" + | "set_external_source_enabled_command" + | "set_external_source_conflict_choice_command" + | "set_external_tool_target_decision_command" + | "set_external_tool_conflict_choice_command" + | "set_external_subagent_activation_command" + | "set_external_subagent_model_binding_command" + | "choose_external_subagent_conflict_command" + | "set_external_mcp_server_decision_command" + | "choose_external_mcp_conflict_command" + | "update_external_integration_policy_command" => { external_sources::dispatch(command, args, state).await } diff --git a/src/apps/cli/src/tui_backend.rs b/src/apps/cli/src/tui_backend.rs index e754f9611..4aa747bdc 100644 --- a/src/apps/cli/src/tui_backend.rs +++ b/src/apps/cli/src/tui_backend.rs @@ -49,16 +49,6 @@ impl std::fmt::Display for TuiBackendError { impl std::error::Error for TuiBackendError {} -fn external_application_v2_unsupported() -> TuiBackendError { - TuiBackendError { - message: "External application V2 is unavailable on this TUI backend".to_string(), - outcome_unknown: false, - kind: TuiBackendErrorKind::Unsupported { - capability: "tui.externalApplicationsV2".to_string(), - }, - } -} - #[async_trait] #[allow(dead_code)] pub(crate) trait TuiBackend: Send + Sync { @@ -297,24 +287,6 @@ pub(crate) trait TuiBackend: Send + Sync { &self, request: ExternalSourceSnapshotRequest, ) -> Result; - async fn external_application_snapshot_v2( - &self, - _request: ExternalApplicationSnapshotRequestV2, - ) -> Result { - Err(external_application_v2_unsupported()) - } - async fn external_application_review_page_v2( - &self, - _request: ExternalApplicationReviewPageRequest, - ) -> Result { - Err(external_application_v2_unsupported()) - } - async fn apply_external_application_action_v2( - &self, - _request: ExternalApplicationActionRequest, - ) -> Result { - Err(external_application_v2_unsupported()) - } async fn external_source_control( &self, request: ExternalSourceControlRequest, @@ -781,34 +753,6 @@ impl TuiBackend for AppServerTuiBackend { map(self.client.external_source_snapshot(request).await) } - async fn external_application_snapshot_v2( - &self, - request: ExternalApplicationSnapshotRequestV2, - ) -> Result { - map(self.client.external_application_snapshot_v2(request).await) - } - - async fn external_application_review_page_v2( - &self, - request: ExternalApplicationReviewPageRequest, - ) -> Result { - map(self - .client - .external_application_review_page_v2(request) - .await) - } - - async fn apply_external_application_action_v2( - &self, - request: ExternalApplicationActionRequest, - ) -> Result { - map_client( - self.client - .apply_external_application_action_v2(request) - .await, - ) - } - async fn external_source_control( &self, request: ExternalSourceControlRequest, @@ -958,10 +902,7 @@ fn backend_error_from_data(message: String, data: AppServerErrorData) -> TuiBack #[cfg(test)] mod tests { - use super::{ - external_application_v2_unsupported, map_protocol_error, TuiBackendErrorKind, TuiEffect, - TuiEffectRoute, - }; + use super::{map_protocol_error, TuiBackendErrorKind, TuiEffect, TuiEffectRoute}; use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; use bitfun_app_server_protocol::external_source::ExternalSourceErrorData; use bitfun_product_domains::external_sources::{ @@ -982,18 +923,6 @@ mod tests { assert_ne!(TuiEffectRoute::AppServer, TuiEffectRoute::HostCapability); } - #[test] - fn unavailable_v2_backend_is_explicitly_read_only() { - let error = external_application_v2_unsupported(); - assert_eq!( - error.kind, - TuiBackendErrorKind::Unsupported { - capability: "tui.externalApplicationsV2".to_string() - } - ); - assert!(!error.outcome_unknown); - } - #[test] fn method_not_found_is_treated_as_an_unsupported_host_method() { let mapped = diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 7bffee62b..2fe4abc5b 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -68,7 +68,6 @@ const DEFAULT_ITEM_ORDER: &[&str] = &[ "mcp_servers", "extensions", "hooks", - "hooks_external", "login", "logout", "status", diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index a863d02d4..0890159ba 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -1,13 +1,10 @@ //! Desktop host API for ecosystem-neutral external AI application sources. use bitfun_core::external_sources::{ - acknowledge_external_ecosystems, - apply_external_application_action_v2 as core_apply_external_application_action_v2, - apply_external_source_control_action, choose_external_mcp_conflict, - choose_external_subagent_conflict, expand_external_prompt_command, - external_source_location_for_host_action, external_source_snapshot, - get_external_application_review_page_v2 as core_get_external_application_review_page_v2, - get_external_application_snapshot_v2 as core_get_external_application_snapshot_v2, + acknowledge_external_ecosystems, apply_external_source_control_action, + choose_external_mcp_conflict, choose_external_subagent_conflict, + expand_external_prompt_command, external_source_location_for_host_action, + external_source_snapshot, get_external_source_control_snapshot as core_get_external_source_control_snapshot, native_prompt_command_conflicts, set_external_mcp_server_decision, set_external_prompt_command_conflict_choice, set_external_source_enabled, @@ -27,11 +24,6 @@ use bitfun_core::service::remote_ssh::workspace_state::{ canonicalize_local_workspace_root, local_workspace_roots_equal, }; use bitfun_core::service::workspace::manager::WorkspaceKind; -use bitfun_product_domains::external_source_control::{ - ExternalApplicationControlRequestV2, ExternalApplicationControlResultV2, - ExternalApplicationHostCapabilitiesV2, ExternalApplicationReviewPageRequestV2, - ExternalApplicationReviewPageV2, ExternalApplicationSnapshotV2, -}; use bitfun_product_domains::external_sources::{ ExternalMcpImportApplyRequestV1, ExternalMcpImportApplyResultV1, ExternalMcpImportPlanV1, }; @@ -50,28 +42,6 @@ pub struct ExternalSourceSnapshotRequest { pub force_refresh: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationSnapshotCommandRequest { - pub workspace_path: Option, - #[serde(default)] - pub force_refresh: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewPageCommandRequest { - pub workspace_path: Option, - pub request: ExternalApplicationReviewPageRequestV2, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationActionCommandRequest { - pub workspace_path: Option, - pub request: ExternalApplicationControlRequestV2, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct WorkspaceReferenceSnapshotRequest { @@ -263,9 +233,6 @@ pub struct ApplyExternalMcpImportRequest { } pub type ExternalSourceSnapshotResponse = ExternalSourcePublicSnapshot; -pub type ExternalApplicationSnapshotResponseV2 = ExternalApplicationSnapshotV2; -pub type ExternalApplicationReviewPageResponseV2 = ExternalApplicationReviewPageV2; -pub type ExternalApplicationActionResponseV2 = ExternalApplicationControlResultV2; pub type ExternalSourceControlResponse = ExternalSourceSurfaceSnapshotV1; pub type ExpandExternalPromptCommandResponse = PromptCommandInvocationOutcome; pub type NativePromptCommandConflictsResponse = NativePromptCommandConflictSnapshot; @@ -314,44 +281,6 @@ pub(super) async fn require_local_workspace( Ok(Some(path)) } -fn ensure_application_v2_workspace_binding( - requested_workspace: Option<&Path>, - current_workspace: Option<&Path>, -) -> ExternalSourceOperationResult<()> { - let Some(requested_workspace) = requested_workspace else { - return Ok(()); - }; - let Some(current_workspace) = current_workspace else { - return Err(ExternalSourceOperationError::invalid_request( - "External application workspace scope requires an active workspace", - )); - }; - if !local_workspace_roots_equal(requested_workspace, current_workspace) { - return Err(ExternalSourceOperationError::invalid_request( - "External application workspace scope does not match the active workspace", - )); - } - Ok(()) -} - -async fn require_application_v2_workspace<'a>( - state: &State<'_, AppState>, - workspace_path: Option<&'a str>, -) -> ExternalSourceOperationResult> { - let workspace = require_local_workspace(workspace_path).await?; - if workspace.is_none() { - return Ok(None); - } - let current_workspace = state.workspace_service.get_current_workspace().await; - ensure_application_v2_workspace_binding( - workspace, - current_workspace - .as_ref() - .map(|workspace| workspace.root_path.as_path()), - )?; - Ok(workspace) -} - #[tauri::command] pub async fn update_external_integration_policy_command( request: UpdateExternalIntegrationPolicyRequest, @@ -374,54 +303,6 @@ pub async fn get_external_source_snapshot( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } -#[tauri::command] -pub async fn get_external_application_snapshot_v2( - state: State<'_, AppState>, - request: ExternalApplicationSnapshotCommandRequest, -) -> ExternalSourceOperationResult { - let workspace = - require_application_v2_workspace(&state, request.workspace_path.as_deref()).await?; - core_get_external_application_snapshot_v2( - workspace, - request.force_refresh, - ExternalApplicationHostCapabilitiesV2::read_write(), - ) - .await - .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) -} - -#[tauri::command] -pub async fn get_external_application_review_page_v2( - state: State<'_, AppState>, - request: ExternalApplicationReviewPageCommandRequest, -) -> ExternalSourceOperationResult { - request - .request - .validate() - .map_err(ExternalSourceOperationError::invalid_request)?; - let workspace = - require_application_v2_workspace(&state, request.workspace_path.as_deref()).await?; - core_get_external_application_review_page_v2(workspace, request.request) - .await - .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) -} - -#[tauri::command] -pub async fn apply_external_application_action_v2( - state: State<'_, AppState>, - request: ExternalApplicationActionCommandRequest, -) -> ExternalSourceOperationResult { - request - .request - .validate() - .map_err(ExternalSourceOperationError::invalid_request)?; - let workspace = - require_application_v2_workspace(&state, request.workspace_path.as_deref()).await?; - core_apply_external_application_action_v2(workspace, request.request) - .await - .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) -} - #[tauri::command] pub async fn get_workspace_reference_snapshot( state: State<'_, AppState>, @@ -825,37 +706,6 @@ mod tests { )); } - #[test] - fn desktop_application_v2_workspace_binding_accepts_only_the_current_workspace() { - let directory = tempfile::tempdir().unwrap(); - let current_root = directory.path().join("current"); - let unrelated_root = directory.path().join("unrelated"); - for path in [¤t_root, &unrelated_root] { - std::fs::create_dir_all(path).unwrap(); - } - - assert!(ensure_application_v2_workspace_binding(None, Some(¤t_root)).is_ok()); - assert!( - ensure_application_v2_workspace_binding(Some(¤t_root), Some(¤t_root)) - .is_ok() - ); - - let unrelated = - ensure_application_v2_workspace_binding(Some(&unrelated_root), Some(¤t_root)) - .unwrap_err(); - assert_eq!( - unrelated.code, - ExternalSourceOperationErrorCode::InvalidRequest - ); - - let missing_current = - ensure_application_v2_workspace_binding(Some(¤t_root), None).unwrap_err(); - assert_eq!( - missing_current.code, - ExternalSourceOperationErrorCode::InvalidRequest - ); - } - #[test] fn desktop_snapshot_never_serializes_prompt_templates() { let snapshot: ExternalSourceCatalogSnapshot = serde_json::from_value(serde_json::json!({ @@ -1026,52 +876,4 @@ mod tests { .is_err() ); } - - #[test] - fn desktop_application_v2_requests_wrap_only_host_scope_and_typed_domain_input() { - let snapshot: ExternalApplicationSnapshotCommandRequest = - serde_json::from_value(serde_json::json!({ - "workspacePath": null, - "forceRefresh": true - })) - .unwrap(); - assert!(snapshot.workspace_path.is_none()); - assert!(snapshot.force_refresh); - - let page: ExternalApplicationReviewPageCommandRequest = - serde_json::from_value(serde_json::json!({ - "workspacePath": "D:/workspace/project", - "request": { - "schemaVersion": 2, - "executionDomainId": "host-a", - "workspaceScopeId": "workspace-a", - "targetScope": "workspace_override", - "reviewId": "review-a", - "preferenceRevision": 2, - "expectedGenerations": [], - "pageSize": 32 - } - })) - .unwrap(); - assert_eq!(page.request.page_size, 32); - - let action: ExternalApplicationActionCommandRequest = - serde_json::from_value(serde_json::json!({ - "workspacePath": "D:/workspace/project", - "request": { - "schemaVersion": 2, - "executionDomainId": "host-a", - "workspaceScopeId": "workspace-a", - "targetScope": "workspace_override", - "operationId": "operation-a", - "expectedPreferenceRevision": 2, - "action": { - "type": "set_application_deferred", - "applicationId": "codex" - } - } - })) - .unwrap(); - assert_eq!(action.request.operation_id, "operation-a"); - } } diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 6a59a2239..a4842d365 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -193,10 +193,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("add_skill", RemoteWorkspacePolicy::LegacyUnaudited), ("analyze_work_state", RemoteWorkspacePolicy::LegacyUnaudited), - ( - "apply_external_application_action_v2", - RemoteWorkspacePolicy::RemoteUnsupported, - ), ( "apply_external_mcp_import_command", RemoteWorkspacePolicy::RemoteUnsupported, @@ -582,14 +578,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_directory_children_paginated", RemoteWorkspacePolicy::LegacyUnaudited, ), - ( - "get_external_application_review_page_v2", - RemoteWorkspacePolicy::RemoteUnsupported, - ), - ( - "get_external_application_snapshot_v2", - RemoteWorkspacePolicy::RemoteUnsupported, - ), ( "get_external_hook_catalog", RemoteWorkspacePolicy::RemoteUnsupported, @@ -2197,25 +2185,6 @@ mod tests { ); } - #[test] - fn external_application_v2_commands_never_fall_back_to_controller_local_state() { - for command in [ - "get_external_application_snapshot_v2", - "get_external_application_review_page_v2", - "apply_external_application_action_v2", - ] { - assert_eq!( - remote_workspace_policy(command), - Some(RemoteWorkspacePolicy::RemoteUnsupported), - "{command} must execute on the workspace Host" - ); - assert!( - registered_commands().contains(command), - "{command} must be registered by Desktop" - ); - } - } - /// `LegacyUnaudited` is a frozen backlog: commands may graduate out of it /// once their remote workspace behavior is audited, but no command may be /// added to it. Do not append to this list; give new commands a real diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 304c97c03..9b566aac5 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1241,9 +1241,6 @@ pub async fn run() { apply_external_hook_import_command, mutate_external_hook_import_command, get_external_source_snapshot, - get_external_application_snapshot_v2, - get_external_application_review_page_v2, - apply_external_application_action_v2, get_workspace_reference_snapshot, plan_external_mcp_import_command, apply_external_mcp_import_command, diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index 4fb106d32..753fa5fb3 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -18,6 +18,11 @@ use bitfun_runtime_ports::{ }; use serde_json::{json, Map}; +#[test] +fn shared_runtime_protocol_stays_at_version_17() { + assert_eq!(PROTOCOL_VERSION, 17); +} + #[test] fn protocol_rejects_unknown_fields_and_operations() { let unknown_field = diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 72d9ff991..0d1016cf4 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -9,25 +9,6 @@ pub use bitfun_product_domains::external_integration_policy::{ ExternalIntegrationPolicyScope, ExternalIntegrationPolicySnapshot, ExternalIntegrationPolicyStatus, }; -use bitfun_product_domains::external_source_control::{ - derive_external_application_status_v2, ExternalApplicationConnectionStateV2, - ExternalApplicationControlActionV2, ExternalApplicationControlRequestV2, - ExternalApplicationControlResultV2, ExternalApplicationDefaultConnectionPolicyV2, - ExternalApplicationDesiredConnectionV2, ExternalApplicationDiscoveryStateV2, - ExternalApplicationHealthV2, ExternalApplicationHostCapabilitiesV2, - ExternalApplicationOperationOutcomeV2, ExternalApplicationOwnerGenerationV2, - ExternalApplicationPrimaryActionV2, ExternalApplicationRecoveryActionV2, - ExternalApplicationReviewCategoryCountV2, ExternalApplicationReviewItemKindV2, - ExternalApplicationReviewItemRefV2, ExternalApplicationReviewItemResultV2, - ExternalApplicationReviewItemV2, ExternalApplicationReviewPageRequestV2, - ExternalApplicationReviewPageV2, ExternalApplicationReviewRecommendationSummaryV2, - ExternalApplicationReviewSelectionBaselineV2, ExternalApplicationReviewSummaryV2, - ExternalApplicationRiskLevelV2, ExternalApplicationRiskSummaryV2, - ExternalApplicationSafetyCeilingV2, ExternalApplicationSnapshotV2, - ExternalApplicationSummaryV2, ExternalApplicationTargetScopeV2, - ExternalApplicationUserDecisionV2, EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, - EXTERNAL_APPLICATION_SCHEMA_V2, -}; pub use bitfun_product_domains::external_source_control::{ ExternalCapabilityKindV1, ExternalSourceControlActionV1, ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, ExternalSourceRuntimeState, ExternalSourceSurfaceSnapshotV1, @@ -146,7 +127,6 @@ pub const EXTERNAL_CAPABILITY_SUBAGENT: &str = "subagent"; pub const EXTERNAL_CAPABILITY_MCP: &str = "mcp"; pub const EXTERNAL_CAPABILITY_REFERENCE: &str = "reference"; const EXTERNAL_ADAPTER_CONTRACT_MAJOR: u32 = 1; -const EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION: u32 = 1; const MAX_PROMPT_COMMAND_FILE_REFERENCES: usize = 8; const MAX_PROMPT_COMMAND_FILE_BYTES: usize = 64 * 1024; const MAX_PROMPT_COMMAND_TOTAL_FILE_BYTES: usize = 128 * 1024; @@ -693,8 +673,6 @@ fn external_capability_descriptor( #[derive(Clone)] struct ExternalEcosystemRegistration { descriptor: ExternalIntegrationEcosystemDescriptor, - default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2, - default_connection_reason: &'static str, contract_major: u32, upstream_format_revision: &'static str, command_provider: Option>, @@ -806,8 +784,6 @@ fn default_external_integration_registry() -> Vec ), ], }, - default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2::Connect, - default_connection_reason: "mature_declarative_support", contract_major: EXTERNAL_ADAPTER_CONTRACT_MAJOR, upstream_format_revision: "opencode-config-v1", command_provider: Some(Arc::new(OpenCodeCommandProvider::default())), @@ -842,8 +818,6 @@ fn default_external_integration_registry() -> Vec ), ], }, - default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2::DiscoverOnly, - default_connection_reason: "explicit_user_connection_required", contract_major: EXTERNAL_ADAPTER_CONTRACT_MAJOR, upstream_format_revision: "claude-code-config-v1", command_provider: Some(Arc::new(ClaudeCodeCommandProvider::default())), @@ -871,8 +845,6 @@ fn default_external_integration_registry() -> Vec ), ], }, - default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2::DiscoverOnly, - default_connection_reason: "explicit_user_connection_required", contract_major: EXTERNAL_ADAPTER_CONTRACT_MAJOR, upstream_format_revision: "codex-config-v1", command_provider: None, @@ -908,49 +880,9 @@ fn default_external_integration_ecosystems() -> Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - application_connections: BTreeMap, #[serde(default)] integration_policy: StoredExternalIntegrationPolicy, /// Bounded recovery history for a policy document written by an @@ -1014,12 +946,6 @@ impl std::fmt::Debug for ExternalSourcesConfig { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("ExternalSourcesConfig") - .field( - "connection_schema_migration_version", - &self.connection_schema_migration_version, - ) - .field("config_origin", &self.config_origin) - .field("application_connections", &self.application_connections) .field("integration_policy", &self.integration_policy) .field( "integration_policy_backups", @@ -1155,6 +1081,57 @@ struct ExternalSourcePreferenceStore { path: PathBuf, } +fn retired_automatic_application_policy() -> ExternalIntegrationPolicyDocument { + let mut policy = ExternalIntegrationPolicyDocument::default(); + policy.user_defaults.enabled = true; + for (ecosystem, mode) in [ + (OPENCODE_ECOSYSTEM_ID, ExternalIntegrationMode::Recommended), + ( + CLAUDE_CODE_ECOSYSTEM_ID, + ExternalIntegrationMode::DiscoverOnly, + ), + (CODEX_ECOSYSTEM_ID, ExternalIntegrationMode::DiscoverOnly), + ] { + policy + .user_defaults + .ecosystems + .entry(EcosystemId::new(ecosystem).expect("built-in ecosystem id is valid")) + .or_default() + .mode = mode; + } + policy +} + +fn normalize_retired_application_defaults(config: &mut ExternalSourcesConfig) { + // The retired application setup enabled integrations automatically. Undo + // only that exact untouched default; every user-authored deviation wins. + let from_retired_automatic_setup = config + .extensions + .get("configOrigin") + .and_then(serde_json::Value::as_str) + == Some("fresh_v2"); + if !from_retired_automatic_setup { + return; + } + + // Consume the retired origin on the first persisted update, including + // documents that already contain a user deviation or application choice. + // Otherwise a later user-authored policy matching the old default could be + // mistaken for untouched setup state and reset again. + config.extensions.remove("configOrigin"); + let has_application_choice = match config.extensions.get("applicationConnections") { + None => false, + Some(serde_json::Value::Object(decisions)) => !decisions.is_empty(), + Some(_) => true, + }; + if has_application_choice { + return; + } + if config.integration_policy.known() == Some(&retired_automatic_application_policy()) { + config.integration_policy = StoredExternalIntegrationPolicy::default(); + } +} + impl ExternalSourcePreferenceStore { fn new(path: PathBuf) -> Self { Self { path } @@ -1174,7 +1151,11 @@ impl ExternalSourcePreferenceStore { JsonFileStore .read_locked_optional(&self.path) .await - .map(|config| config.unwrap_or_default()) + .map(|config| { + let mut config = config.unwrap_or_default(); + normalize_retired_application_defaults(&mut config); + config + }) .map_err(|error| error.to_string()) } @@ -1183,352 +1164,13 @@ impl ExternalSourcePreferenceStore { update: impl FnOnce(&mut ExternalSourcesConfig) -> R, ) -> Result<(R, ExternalSourcesConfig), String> { JsonFileStore - .update_locked(&self.path, ExternalSourcesConfig::default(), update) + .update_locked(&self.path, ExternalSourcesConfig::default(), |config| { + normalize_retired_application_defaults(config); + update(config) + }) .await .map_err(|error| error.to_string()) } - - async fn ensure_application_connection_schema( - &self, - _execution_domain_id: &str, - ) -> Result { - let json_store = JsonFileStore; - let _lock = json_store - .acquire_cross_process_lock(&self.path) - .await - .map_err(|error| error.to_string())?; - let existing = json_store - .read_optional::(&self.path) - .await - .map_err(|error| error.to_string())?; - let was_missing = existing.is_none(); - let mut changed = was_missing; - - let mut config = match existing { - Some(config) => config, - None => { - let mut config = ExternalSourcesConfig::default(); - apply_fresh_v2_product_defaults(&mut config)?; - config.config_origin = Some(ExternalSourcesConfigOrigin::FreshV2); - config - } - }; - if config.integration_policy.known().is_none() { - return Err(format!( - "policy_unavailable: external integration policy schema major {} is not supported", - config.integration_policy.schema_major() - )); - } - if config.connection_schema_migration_version - > EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION - { - return Err(format!( - "policy_unavailable: external application connection schema version {} is not supported", - config.connection_schema_migration_version - )); - } - if config.connection_schema_migration_version - < EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION - { - if !was_missing && config.config_origin != Some(ExternalSourcesConfigOrigin::FreshV2) { - migrate_legacy_application_connections(&mut config, _execution_domain_id)?; - } - config.connection_schema_migration_version = - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION; - config - .config_origin - .get_or_insert(ExternalSourcesConfigOrigin::LegacyMigration); - changed = true; - } - changed |= ensure_mcp_revision_secret(&mut config); - if changed { - json_store - .write_atomic_strict(&self.path, &config) - .await - .map_err(|error| error.to_string())?; - } - Ok(config) - } -} - -fn is_zero_u32(value: &u32) -> bool { - *value == 0 -} - -fn apply_fresh_v2_product_defaults(config: &mut ExternalSourcesConfig) -> Result<(), String> { - let policy = config.integration_policy.known_mut().ok_or_else(|| { - "policy_unavailable: external integration policy is incompatible".to_string() - })?; - policy.user_defaults.enabled = true; - for registration in default_external_integration_registry() { - let mode = match registration.default_connection_policy { - ExternalApplicationDefaultConnectionPolicyV2::Connect => { - ExternalIntegrationMode::Recommended - } - ExternalApplicationDefaultConnectionPolicyV2::DiscoverOnly - | ExternalApplicationDefaultConnectionPolicyV2::Unsupported => { - ExternalIntegrationMode::DiscoverOnly - } - }; - policy - .user_defaults - .ecosystems - .entry(registration.descriptor.ecosystem_id) - .or_default() - .mode = mode; - } - Ok(()) -} - -fn external_application_connection_key( - execution_domain_id: &str, - application_id: &str, - workspace_scope_id: Option<&str>, -) -> String { - format!( - "{execution_domain_id}\u{1f}{application_id}\u{1f}{}", - workspace_scope_id.unwrap_or("user_default") - ) -} - -fn migrate_legacy_application_connections( - config: &mut ExternalSourcesConfig, - execution_domain_id: &str, -) -> Result<(), String> { - let document = config.integration_policy.known().ok_or_else(|| { - "policy_unavailable: external integration policy is incompatible".to_string() - })?; - let workspace_scope_ids = document - .workspace_overrides - .keys() - .cloned() - .collect::>(); - let reset_origin = config.config_origin == Some(ExternalSourcesConfigOrigin::IncompatibleReset); - let mut decisions = BTreeMap::new(); - for workspace_scope_id in std::iter::once(None).chain( - workspace_scope_ids - .iter() - .map(|workspace_scope_id| Some(workspace_scope_id.as_str())), - ) { - let policy = external_integration_policy_snapshot( - document, - workspace_scope_id, - default_external_integration_ecosystems(), - ) - .map_err(|error| format!("policy_unavailable: {error}"))?; - for descriptor in &policy.registered_ecosystems { - let (desired_connection, decision_origin) = if reset_origin { - ( - StoredExternalApplicationDesiredConnection::Disconnected, - StoredExternalApplicationDecisionOrigin::IncompatibleReset, - ) - } else { - legacy_application_connection_decision(&policy.effective, &descriptor.ecosystem_id) - }; - decisions.insert( - external_application_connection_key( - execution_domain_id, - descriptor.ecosystem_id.as_str(), - workspace_scope_id, - ), - StoredExternalApplicationConnectionDecision { - desired_connection, - decision_origin, - }, - ); - } - } - config.application_connections = decisions; - Ok(()) -} - -fn legacy_application_connection_decision( - policy: &EffectiveExternalIntegrationPolicy, - ecosystem_id: &EcosystemId, -) -> ( - StoredExternalApplicationDesiredConnection, - StoredExternalApplicationDecisionOrigin, -) { - let Some(ecosystem) = policy.ecosystems.get(ecosystem_id) else { - return ( - StoredExternalApplicationDesiredConnection::NeedsReview, - StoredExternalApplicationDecisionOrigin::LegacyNeedsReview, - ); - }; - if !policy.enabled - || matches!( - ecosystem.mode, - ExternalIntegrationMode::Disabled - | ExternalIntegrationMode::DiscoverOnly - | ExternalIntegrationMode::Unknown(_) - ) - { - return ( - StoredExternalApplicationDesiredConnection::Disconnected, - StoredExternalApplicationDecisionOrigin::LegacySafety, - ); - } - if ecosystem.capabilities.values().any(|access| { - matches!( - access, - ExternalIntegrationAccess::AskBeforeUse | ExternalIntegrationAccess::Auto - ) - }) { - return ( - StoredExternalApplicationDesiredConnection::Connected, - StoredExternalApplicationDecisionOrigin::LegacyActive, - ); - } - ( - StoredExternalApplicationDesiredConnection::NeedsReview, - StoredExternalApplicationDecisionOrigin::LegacyNeedsReview, - ) -} - -fn apply_external_application_connection_decision( - config: &mut ExternalSourcesConfig, - execution_domain_id: &str, - target_scope: ExternalApplicationTargetScopeV2, - workspace_scope_id: Option<&str>, - application_id: &str, - desired_connection: StoredExternalApplicationDesiredConnection, - expected_preference_revision: u64, -) -> Result { - if config.preference_revision != expected_preference_revision { - return Err(stale_operation_error( - "External application preferences changed; refresh before retrying", - )); - } - if config.connection_schema_migration_version != EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION - { - return Err(incompatible_policy_error( - "External application connection preferences require migration", - )); - } - let registration = default_external_integration_registry() - .into_iter() - .find(|registration| registration.descriptor.ecosystem_id.as_str() == application_id) - .ok_or_else(|| { - invalid_operation_error(format!( - "External application '{application_id}' is not registered" - )) - })?; - match (target_scope, workspace_scope_id) { - (ExternalApplicationTargetScopeV2::UserDefault, None) - | (ExternalApplicationTargetScopeV2::WorkspaceOverride, Some(_)) => {} - (ExternalApplicationTargetScopeV2::UserDefault, Some(_)) => { - return Err(invalid_operation_error( - "User-default application decisions cannot include a workspace scope", - )); - } - (ExternalApplicationTargetScopeV2::WorkspaceOverride, None) => { - return Err(invalid_operation_error( - "Workspace application decisions require a workspace scope", - )); - } - } - let policy = config.integration_policy.known_mut().ok_or_else(|| { - incompatible_policy_error("External integration policy requires a backup and reset") - })?; - let mode = match desired_connection { - StoredExternalApplicationDesiredConnection::Connected => { - ExternalIntegrationMode::Recommended - } - StoredExternalApplicationDesiredConnection::Disconnected - | StoredExternalApplicationDesiredConnection::Deferred => ExternalIntegrationMode::Disabled, - StoredExternalApplicationDesiredConnection::NeedsReview => { - ExternalIntegrationMode::DiscoverOnly - } - }; - let policy_changed = match target_scope { - ExternalApplicationTargetScopeV2::UserDefault => { - let enabled_changed = desired_connection - == StoredExternalApplicationDesiredConnection::Connected - && !policy.user_defaults.enabled; - if enabled_changed { - policy.user_defaults.enabled = true; - } - let ecosystem = policy - .user_defaults - .ecosystems - .entry(registration.descriptor.ecosystem_id.clone()) - .or_default(); - let mode_changed = ecosystem.mode != mode; - ecosystem.mode = mode; - enabled_changed || mode_changed - } - ExternalApplicationTargetScopeV2::WorkspaceOverride => { - let workspace_scope_id = workspace_scope_id - .expect("workspace scope was validated before applying its policy"); - let workspace = policy - .workspace_overrides - .entry(workspace_scope_id.to_string()) - .or_default(); - let enabled_changed = desired_connection - == StoredExternalApplicationDesiredConnection::Connected - && workspace.enabled != Some(true); - if enabled_changed { - workspace.enabled = Some(true); - } - let ecosystem = workspace - .ecosystems - .entry(registration.descriptor.ecosystem_id.clone()) - .or_default(); - let mode_changed = ecosystem.mode.as_ref() != Some(&mode); - ecosystem.mode = Some(mode); - enabled_changed || mode_changed - } - }; - let key = external_application_connection_key( - execution_domain_id, - application_id, - workspace_scope_id, - ); - let decision = StoredExternalApplicationConnectionDecision { - desired_connection, - decision_origin: StoredExternalApplicationDecisionOrigin::User, - }; - let decision_changed = config.application_connections.get(&key) != Some(&decision); - if decision_changed { - config.application_connections.insert(key, decision); - } - let changed = policy_changed || decision_changed; - if changed { - config.preference_revision = config.preference_revision.saturating_add(1); - } - Ok(changed) -} - -fn external_application_action_scope_matches( - current_workspace_scope_id: Option<&str>, - target_scope: ExternalApplicationTargetScopeV2, - requested_workspace_scope_id: Option<&str>, -) -> bool { - match target_scope { - ExternalApplicationTargetScopeV2::UserDefault => requested_workspace_scope_id.is_none(), - ExternalApplicationTargetScopeV2::WorkspaceOverride => { - current_workspace_scope_id.is_some() - && current_workspace_scope_id == requested_workspace_scope_id - } - } -} - -fn ensure_mcp_revision_secret(config: &mut ExternalSourcesConfig) -> bool { - if config - .mcp_revision_secret - .as_deref() - .and_then(decode_mcp_revision_key) - .is_some() - { - return false; - } - let first = uuid::Uuid::new_v4(); - let second = uuid::Uuid::new_v4(); - let mut bytes = [0_u8; 32]; - bytes[..16].copy_from_slice(first.as_bytes()); - bytes[16..].copy_from_slice(second.as_bytes()); - config.mcp_revision_secret = Some(hex::encode(bytes)); - true } fn decode_mcp_revision_key(value: &str) -> Option { @@ -1537,38 +1179,37 @@ fn decode_mcp_revision_key(value: &str) -> Option { Some(ExternalMcpRevisionKey::new(bytes)) } -fn legacy_config_after_migration_failure( - config: ExternalSourcesConfig, - migration_error: String, +async fn external_sources_config_with_mcp_revision_key( ) -> Result<(ExternalSourcesConfig, ExternalMcpRevisionKey), String> { - let revision_key = config + let store = ExternalSourcePreferenceStore::global()?; + let config = store.read().await?; + if let Some(revision_key) = config .mcp_revision_secret .as_deref() .and_then(decode_mcp_revision_key) - .ok_or(migration_error)?; - Ok((config, revision_key)) -} - -async fn external_sources_config_with_mcp_revision_key( -) -> Result<(ExternalSourcesConfig, ExternalMcpRevisionKey), String> { - let store = ExternalSourcePreferenceStore::global()?; - let config = match store - .ensure_application_connection_schema(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .await { - Ok(config) => config, - Err(migration_error) => { - let legacy = store.read().await.map_err(|read_error| { - format!("{migration_error}; legacy preferences could not be read: {read_error}") - })?; - let fallback = legacy_config_after_migration_failure(legacy, migration_error.clone())?; - log::warn!( - "External application preference migration failed; continuing with legacy V1 preferences reason={}", - safe_external_log_token(&migration_error), - ); - return Ok(fallback); - } + return Ok((config, revision_key)); + } + let generated = { + let first = uuid::Uuid::new_v4(); + let second = uuid::Uuid::new_v4(); + let mut bytes = [0_u8; 32]; + bytes[..16].copy_from_slice(first.as_bytes()); + bytes[16..].copy_from_slice(second.as_bytes()); + bytes }; + let (_, config) = store + .update(|config| { + if config + .mcp_revision_secret + .as_deref() + .and_then(decode_mcp_revision_key) + .is_none() + { + config.mcp_revision_secret = Some(hex::encode(generated)); + } + }) + .await?; let revision_key = config .mcp_revision_secret .as_deref() @@ -3304,469 +2945,18 @@ impl WorkspaceExternalSourceService { } } - fn application_snapshot_v2( - &self, - preferences: &ExternalSourcesConfig, - host_capabilities: ExternalApplicationHostCapabilitiesV2, - ) -> Result { - let catalog = self.snapshot(); - let workspace_scope_id = workspace_policy_key(self.workspace_root.as_deref()); - let target_scope = if workspace_scope_id.is_some() { - ExternalApplicationTargetScopeV2::WorkspaceOverride - } else { - ExternalApplicationTargetScopeV2::UserDefault - }; - let source_ecosystems = catalog - .sources - .iter() - .map(|source| { - ( - source.record.key.clone(), - source.record.ecosystem_id.clone(), - ) - }) - .collect::>(); - let subagents_by_candidate_id = catalog - .subagents - .iter() - .map(|subagent| (subagent.candidate_id.as_str(), subagent)) - .collect::>(); - let applications = default_external_integration_registry() - .into_iter() - .map(|registration| { - project_external_application_v2( - &catalog, - preferences, - self.execution_domain_id.as_str(), - workspace_scope_id.as_deref(), - registration, - host_capabilities, - &source_ecosystems, - &subagents_by_candidate_id, - ) - }) - .collect::>(); - let review_summary = external_application_review_summary( - &catalog, + fn safe_mode_enabled(&self) -> bool { + external_source_safe_mode_enabled_for( self.execution_domain_id.as_str(), - workspace_scope_id.as_deref(), - target_scope, - preferences.preference_revision, - &subagents_by_candidate_id, - ); - let snapshot = ExternalApplicationSnapshotV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: self.execution_domain_id.clone(), - workspace_scope_id, - effective_connection_scope: target_scope, - refresh_generation: catalog.generation, - preference_revision: preferences.preference_revision, - safe_mode: self.safe_mode_enabled(), - host_capabilities, - applications, - review_summary, - }; - snapshot - .validate() - .map_err(|error| format!("invalid external application projection: {error}"))?; - Ok(snapshot) + &workspace_route_key(self.workspace_root.as_deref()), + ) } - fn application_review_page_v2( - &self, - preferences: &ExternalSourcesConfig, - request: ExternalApplicationReviewPageRequestV2, - ) -> Result { - request - .validate() - .map_err(|error| invalid_operation_error(error))?; - let workspace_scope_id = workspace_policy_key(self.workspace_root.as_deref()); - let target_scope = if workspace_scope_id.is_some() { - ExternalApplicationTargetScopeV2::WorkspaceOverride - } else { - ExternalApplicationTargetScopeV2::UserDefault - }; - if request.execution_domain_id != self.execution_domain_id - || request.workspace_scope_id != workspace_scope_id - || request.target_scope != target_scope - { - return Err(stale_operation_error( - "External application review belongs to a different Host or workspace", - )); - } - let catalog = self.snapshot(); - let plan = external_application_review_plan( - &catalog, + fn write_safe_mode(&self, enabled: bool) { + set_external_source_safe_mode_for( self.execution_domain_id.as_str(), - workspace_scope_id.as_deref(), - target_scope, - preferences.preference_revision, - ); - let opening_request = request.cursor.is_none() && request.expected_generations.is_empty(); - if request.preference_revision != preferences.preference_revision - || (!opening_request - && (request.review_id != plan.review_id - || request.expected_generations != plan.expected_generations)) - { - return Err(stale_operation_error( - "External application review changed; refresh before continuing", - )); - } - let offset = match request.cursor.as_deref() { - None => 0, - Some(cursor) => plan.parse_cursor(cursor)?, - }; - let end = offset - .saturating_add(request.page_size) - .min(plan.items.len()); - let items = plan.items[offset..end].to_vec(); - let next_cursor = (end < plan.items.len()).then(|| plan.cursor(end)); - let page = ExternalApplicationReviewPageV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: self.execution_domain_id.clone(), - workspace_scope_id, - target_scope, - review_id: plan.review_id, - preference_revision: preferences.preference_revision, - expected_generations: plan.expected_generations, - cursor: request.cursor, - next_cursor, - total_count: plan.items.len(), - items, - }; - page.validate() - .map_err(|error| format!("invalid external application review projection: {error}"))?; - Ok(page) - } - - async fn apply_application_action_v2( - self: &Arc, - request: ExternalApplicationControlRequestV2, - ) -> Result { - request - .validate() - .map_err(|error| invalid_operation_error(error))?; - let workspace_scope_id = workspace_policy_key(self.workspace_root.as_deref()); - if request.execution_domain_id != self.execution_domain_id - || !external_application_action_scope_matches( - workspace_scope_id.as_deref(), - request.target_scope, - request.workspace_scope_id.as_deref(), - ) - { - return Err(stale_operation_error( - "External application action belongs to a different Host or workspace", - )); - } - let operation_id = request.operation_id; - let expected_preference_revision = request.expected_preference_revision; - let item_results = match request.action { - ExternalApplicationControlActionV2::Refresh => { - self.refresh_with_runtime_invalidation().await?; - Vec::new() - } - ExternalApplicationControlActionV2::SetSafeMode { enabled } => { - self.set_safe_mode(enabled, Some(expected_preference_revision)) - .await?; - Vec::new() - } - ExternalApplicationControlActionV2::SetSourceEnabled { - source_key, - enabled, - } => { - self.set_source_enabled(&source_key, enabled, expected_preference_revision) - .await?; - Vec::new() - } - ExternalApplicationControlActionV2::ConnectApplication { application_id } => { - self.persist_application_connection( - request.target_scope, - request.workspace_scope_id.as_deref(), - &application_id, - StoredExternalApplicationDesiredConnection::Connected, - expected_preference_revision, - ) - .await?; - Vec::new() - } - ExternalApplicationControlActionV2::DisconnectApplication { application_id } => { - self.persist_application_connection( - request.target_scope, - request.workspace_scope_id.as_deref(), - &application_id, - StoredExternalApplicationDesiredConnection::Disconnected, - expected_preference_revision, - ) - .await?; - Vec::new() - } - ExternalApplicationControlActionV2::SetApplicationDeferred { application_id } => { - self.persist_application_connection( - request.target_scope, - request.workspace_scope_id.as_deref(), - &application_id, - StoredExternalApplicationDesiredConnection::Deferred, - expected_preference_revision, - ) - .await?; - Vec::new() - } - ExternalApplicationControlActionV2::SubmitApplicationReview { - review_id, - expected_generations, - selection_baseline, - selection_overrides, - } => { - self.apply_application_review_v2( - request.target_scope, - request.workspace_scope_id.as_deref(), - expected_preference_revision, - &review_id, - expected_generations, - selection_baseline, - selection_overrides, - ) - .await? - } - }; - let preferences = read_external_sources_config().await?; - let outcome = if item_results - .iter() - .any(|item| item.outcome != ExternalApplicationOperationOutcomeV2::Applied) - && !item_results - .iter() - .any(|item| item.outcome == ExternalApplicationOperationOutcomeV2::Applied) - { - item_results - .iter() - .map(|item| item.outcome) - .next() - .unwrap_or(ExternalApplicationOperationOutcomeV2::Applied) - } else { - ExternalApplicationOperationOutcomeV2::Applied - }; - let result = ExternalApplicationControlResultV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - operation_id, - preference_revision: preferences.preference_revision, - outcome, - item_results, - }; - result - .validate() - .map_err(|error| format!("invalid external application action result: {error}"))?; - Ok(result) - } - - async fn persist_application_connection( - self: &Arc, - target_scope: ExternalApplicationTargetScopeV2, - workspace_scope_id: Option<&str>, - application_id: &str, - desired_connection: StoredExternalApplicationDesiredConnection, - expected_preference_revision: u64, - ) -> Result<(), String> { - let workspace_scope_id = workspace_scope_id.map(str::to_string); - let execution_domain_id = self.execution_domain_id.to_string(); - let application_id = application_id.to_string(); - let (result, preferences) = ExternalSourcePreferenceStore::global()? - .update(move |config| { - apply_external_application_connection_decision( - config, - &execution_domain_id, - target_scope, - workspace_scope_id.as_deref(), - &application_id, - desired_connection, - expected_preference_revision, - ) - }) - .await?; - result?; - propagate_integration_policy_preferences(&preferences, self); - self.refresh_preserving_worker_recovery().await?; - Ok(()) - } - - async fn apply_application_review_v2( - &self, - target_scope: ExternalApplicationTargetScopeV2, - workspace_scope_id: Option<&str>, - expected_preference_revision: u64, - review_id: &str, - expected_generations: Vec, - selection_baseline: ExternalApplicationReviewSelectionBaselineV2, - selection_overrides: Vec< - bitfun_product_domains::external_source_control::ExternalApplicationReviewSelectionOverrideV2, - >, - ) -> Result, String> { - if selection_overrides.len() > EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS { - return Err(invalid_operation_error( - "External application review has too many selection overrides", - )); - } - let catalog = self.snapshot(); - let plan = external_application_review_plan( - &catalog, - self.execution_domain_id.as_str(), - workspace_scope_id, - target_scope, - expected_preference_revision, - ); - if review_id != plan.review_id - || expected_generations != plan.expected_generations - || expected_preference_revision != catalog.preference_revision - { - return Err(stale_operation_error( - "External application review changed; refresh before applying it", - )); - } - let plan_refs = plan - .items - .iter() - .map(|item| item.item_ref.clone()) - .collect::>(); - let mut overrides = BTreeMap::new(); - for selection in selection_overrides { - if !plan_refs.contains(&selection.item_ref) - || overrides - .insert(selection.item_ref, selection.selected) - .is_some() - { - return Err(stale_operation_error( - "External application review selections no longer match the current plan", - )); - } - } - let mut current_revision = expected_preference_revision; - let mut results = Vec::with_capacity(plan.items.len()); - for item in plan.items { - if item.safety_ceiling == ExternalApplicationSafetyCeilingV2::Blocked { - results.push(ExternalApplicationReviewItemResultV2 { - item_ref: item.item_ref, - outcome: ExternalApplicationOperationOutcomeV2::Blocked, - reason_code: Some("resolve_conflict".to_string()), - recovery_actions: vec![ExternalApplicationRecoveryActionV2::ResolveConflict], - }); - continue; - } - let selected = - overrides - .get(&item.item_ref) - .copied() - .unwrap_or(match selection_baseline { - ExternalApplicationReviewSelectionBaselineV2::Recommended => { - item.recommended - } - ExternalApplicationReviewSelectionBaselineV2::None => false, - }); - let outcome = self - .apply_application_review_item(&catalog, &item.item_ref, selected, current_revision) - .await; - match outcome { - Ok(snapshot) => { - current_revision = snapshot.preference_revision; - results.push(ExternalApplicationReviewItemResultV2 { - item_ref: item.item_ref, - outcome: ExternalApplicationOperationOutcomeV2::Applied, - reason_code: None, - recovery_actions: Vec::new(), - }); - } - Err(error) => { - let (outcome, reason_code, recovery_actions) = - external_application_item_failure(&error); - results.push(ExternalApplicationReviewItemResultV2 { - item_ref: item.item_ref, - outcome, - reason_code: Some(reason_code), - recovery_actions, - }); - } - } - } - Ok(results) - } - - async fn apply_application_review_item( - &self, - catalog: &ExternalSourceCatalogSnapshot, - item_ref: &ExternalApplicationReviewItemRefV2, - selected: bool, - expected_preference_revision: u64, - ) -> Result { - match item_ref.kind { - ExternalApplicationReviewItemKindV2::Tool => { - let request = catalog - .tool_approval_requests - .iter() - .find(|request| request.approval_key == item_ref.stable_id) - .ok_or_else(|| { - missing_candidate_error("External tool review item is no longer available") - })?; - self.set_tool_target_decision( - &request.approval_key, - &request.decision_key, - selected, - expected_preference_revision, - ) - .await - } - ExternalApplicationReviewItemKindV2::Mcp => { - let request = catalog - .mcp_approval_requests - .iter() - .find(|request| request.decision_key == item_ref.stable_id) - .ok_or_else(|| { - missing_candidate_error("External MCP review item is no longer available") - })?; - self.set_mcp_server_decision( - &request.candidate_id, - &request.decision_key, - selected, - catalog.mcp_generation, - expected_preference_revision, - ) - .await - } - ExternalApplicationReviewItemKindV2::Subagent => { - let summary = catalog - .subagents - .iter() - .find(|summary| summary.decision_key == item_ref.stable_id) - .ok_or_else(|| { - missing_candidate_error( - "External subagent review item is no longer available", - ) - })?; - self.set_subagent_activation( - &summary.candidate_id, - selected, - catalog.subagent_generation, - expected_preference_revision, - &summary.decision_key, - ) - .await - } - ExternalApplicationReviewItemKindV2::Command - | ExternalApplicationReviewItemKindV2::Conflict => Err(conflict_operation_error( - "External conflict review requires an explicit owner choice", - )), - } - } - - fn safe_mode_enabled(&self) -> bool { - external_source_safe_mode_enabled_for( - self.execution_domain_id.as_str(), - &workspace_route_key(self.workspace_root.as_deref()), - ) - } - - fn write_safe_mode(&self, enabled: bool) { - set_external_source_safe_mode_for( - self.execution_domain_id.as_str(), - &workspace_route_key(self.workspace_root.as_deref()), - enabled, + &workspace_route_key(self.workspace_root.as_deref()), + enabled, ); } @@ -4798,847 +3988,90 @@ impl WorkspaceExternalSourceService { } } -#[derive(Default)] -struct ExternalApplicationAggregateCounts { - enabled: usize, - pending_review: usize, - blocked: usize, - conflicts: usize, +fn lock_coordinator( + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalSourceCoordinator> { + control_plane.lock_commands() +} + +fn lock_tool_coordinator( + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalToolCoordinator> { + control_plane.lock_tools() } -struct ExternalApplicationReviewPlan { - review_id: String, - expected_generations: Vec, - items: Vec, - summary_items: Vec, +fn lock_subagent_coordinator( + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalSubagentCoordinator> { + control_plane.lock_subagents() } -struct ExternalApplicationReviewSummaryItem { - item_ref: ExternalApplicationReviewItemRefV2, - recommended: bool, - safety_ceiling: ExternalApplicationSafetyCeilingV2, +fn lock_mcp_coordinator( + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalMcpCoordinator> { + control_plane.lock_mcp() } -impl ExternalApplicationReviewPlan { - fn summary(&self) -> Option { - if self.summary_items.is_empty() { - return None; - } - let mut counts = BTreeMap::new(); - for item in &self.summary_items { - *counts.entry(item.item_ref.kind).or_insert(0usize) += 1; - } - let recommended_count = self - .summary_items - .iter() - .filter(|item| item.recommended) - .count(); - let blocked_count = self - .summary_items - .iter() - .filter(|item| item.safety_ceiling == ExternalApplicationSafetyCeilingV2::Blocked) - .count(); - Some(ExternalApplicationReviewSummaryV2 { - review_id: self.review_id.clone(), - total_count: self.summary_items.len(), - category_counts: counts - .into_iter() - .map(|(kind, count)| ExternalApplicationReviewCategoryCountV2 { kind, count }) - .collect(), - max_selection_count: self.summary_items.len().saturating_sub(blocked_count), - risk_summary: ExternalApplicationRiskSummaryV2 { - highest_level: Some(ExternalApplicationRiskLevelV2::High), - reason_codes: vec!["executable_content_requires_review".to_string()], - }, - recommendation_summary: ExternalApplicationReviewRecommendationSummaryV2 { - recommended_count, - optional_count: self - .summary_items - .len() - .saturating_sub(recommended_count) - .saturating_sub(blocked_count), - blocked_count, - }, - safety_ceiling: if blocked_count == self.summary_items.len() { - ExternalApplicationSafetyCeilingV2::Blocked - } else { - ExternalApplicationSafetyCeilingV2::ReviewRequired - }, - }) - } +fn lock_workspace_reference_coordinator( + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalWorkspaceReferenceCoordinator> { + control_plane.lock_workspace_references() +} - fn cursor(&self, offset: usize) -> String { - format!("{}:{offset}", self.review_id) +fn lock_snapshot( + snapshot: &StdMutex, +) -> MutexGuard<'_, ExternalSourceCatalogSnapshot> { + match snapshot.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), } +} - fn parse_cursor(&self, cursor: &str) -> Result { - let offset = cursor - .strip_prefix(&format!("{}:", self.review_id)) - .and_then(|offset| offset.parse::().ok()) - .filter(|offset| *offset < self.items.len()) - .ok_or_else(|| { - stale_operation_error( - "External application review cursor changed; restart the review", - ) - })?; - Ok(offset) - } +static WORKSPACE_SERVICES: OnceLock< + DashMap, Weak>, +> = OnceLock::new(); +static READ_ONLY_WORKSPACE_SERVICES: OnceLock< + DashMap, Weak>, +> = OnceLock::new(); +static SAFE_MODE_WORKSPACES: OnceLock> = OnceLock::new(); + +fn safe_mode_workspaces() -> &'static DashMap { + SAFE_MODE_WORKSPACES.get_or_init(DashMap::new) } +static TOOL_REGISTRY_CHANGE_EPOCH: AtomicU64 = AtomicU64::new(0); +static TOOL_REGISTRY_REBUILD_SCHEDULED: AtomicBool = AtomicBool::new(false); -fn external_application_review_plan( - catalog: &ExternalSourceCatalogSnapshot, - execution_domain_id: &str, - workspace_scope_id: Option<&str>, - target_scope: ExternalApplicationTargetScopeV2, - preference_revision: u64, -) -> ExternalApplicationReviewPlan { - let subagents_by_candidate_id = catalog - .subagents - .iter() - .map(|subagent| (subagent.candidate_id.as_str(), subagent)) - .collect::>(); - external_application_review_plan_internal( - catalog, - execution_domain_id, - workspace_scope_id, - target_scope, - preference_revision, - &subagents_by_candidate_id, - true, - ) +fn workspace_services() -> &'static DashMap, Weak> { + WORKSPACE_SERVICES.get_or_init(DashMap::new) } -fn external_application_review_summary( - catalog: &ExternalSourceCatalogSnapshot, - execution_domain_id: &str, - workspace_scope_id: Option<&str>, - target_scope: ExternalApplicationTargetScopeV2, - preference_revision: u64, - subagents_by_candidate_id: &BTreeMap<&str, &ExternalSubagentSummary>, -) -> Option { - external_application_review_plan_internal( - catalog, - execution_domain_id, - workspace_scope_id, - target_scope, - preference_revision, - subagents_by_candidate_id, - false, - ) - .summary() -} - -fn push_external_application_review_item( - items: &mut Vec, - summary_items: &mut Vec, - include_item_details: bool, - item_ref: ExternalApplicationReviewItemRefV2, - recommended: bool, - safety_ceiling: ExternalApplicationSafetyCeilingV2, - build_item: F, -) where - F: FnOnce(ExternalApplicationReviewItemRefV2) -> ExternalApplicationReviewItemV2, -{ - summary_items.push(ExternalApplicationReviewSummaryItem { - item_ref: item_ref.clone(), - recommended, - safety_ceiling, - }); - if include_item_details { - items.push(build_item(item_ref)); +fn read_only_workspace_services( +) -> &'static DashMap, Weak> { + READ_ONLY_WORKSPACE_SERVICES.get_or_init(DashMap::new) +} + +fn workspace_services_for_profile( + profile: ExternalSourceServiceProfile, +) -> &'static DashMap, Weak> { + match profile { + ExternalSourceServiceProfile::LocalExecution => workspace_services(), + ExternalSourceServiceProfile::ReadOnlyProjection => read_only_workspace_services(), } } -fn push_external_application_conflict_review_item( - items: &mut Vec, - summary_items: &mut Vec, - include_item_details: bool, - conflict_key: String, - build_display: F, -) where - F: FnOnce() -> (String, String), -{ - let item_ref = ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Conflict, - stable_id: conflict_key, - }; - push_external_application_review_item( - items, - summary_items, - include_item_details, - item_ref, - false, - ExternalApplicationSafetyCeilingV2::Blocked, - |item_ref| { - let (display_name, display_summary) = build_display(); - ExternalApplicationReviewItemV2 { - item_ref, - display_name, - display_summary, - risk_level: ExternalApplicationRiskLevelV2::High, - risk_reason_codes: vec!["ambiguous_runtime_route".to_string()], - recommended: false, - safety_ceiling: ExternalApplicationSafetyCeilingV2::Blocked, - } - }, - ); +fn workspace_service_gate() -> &'static tokio::sync::Mutex<()> { + static GATE: OnceLock> = OnceLock::new(); + GATE.get_or_init(|| tokio::sync::Mutex::new(())) } -fn external_application_review_plan_internal( - catalog: &ExternalSourceCatalogSnapshot, - execution_domain_id: &str, - workspace_scope_id: Option<&str>, - target_scope: ExternalApplicationTargetScopeV2, - preference_revision: u64, - subagents_by_candidate_id: &BTreeMap<&str, &ExternalSubagentSummary>, - include_item_details: bool, -) -> ExternalApplicationReviewPlan { - let mut items = Vec::new(); - let mut summary_items = Vec::new(); - for request in &catalog.tool_approval_requests { - let item_ref = ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: request.approval_key.clone(), - }; - push_external_application_review_item( - &mut items, - &mut summary_items, - include_item_details, - item_ref, - false, - ExternalApplicationSafetyCeilingV2::ReviewRequired, - |item_ref| ExternalApplicationReviewItemV2 { - item_ref, - display_name: request.source_display_name.clone(), - display_summary: format!( - "{} external tool{} require approval", - request.tool_names.len(), - if request.tool_names.len() == 1 { - "" - } else { - "s" - } - ), - risk_level: ExternalApplicationRiskLevelV2::High, - risk_reason_codes: vec!["process_or_resource_access".to_string()], - recommended: false, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - }, - ); - } - for request in &catalog.mcp_approval_requests { - let item_ref = ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Mcp, - stable_id: request.decision_key.clone(), - }; - push_external_application_review_item( - &mut items, - &mut summary_items, - include_item_details, - item_ref, - false, - ExternalApplicationSafetyCeilingV2::ReviewRequired, - |item_ref| ExternalApplicationReviewItemV2 { - item_ref, - display_name: request.definition.name.clone(), - display_summary: "External MCP server requires approval".to_string(), - risk_level: ExternalApplicationRiskLevelV2::High, - risk_reason_codes: vec!["process_or_network_access".to_string()], - recommended: false, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - }, - ); - } - for candidate_id in &catalog.pending_subagent_approvals { - let Some(summary) = subagents_by_candidate_id - .get(candidate_id.as_str()) - .copied() - else { - continue; - }; - let item_ref = ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Subagent, - stable_id: summary.decision_key.clone(), - }; - push_external_application_review_item( - &mut items, - &mut summary_items, - include_item_details, - item_ref, - false, - ExternalApplicationSafetyCeilingV2::ReviewRequired, - |item_ref| ExternalApplicationReviewItemV2 { - item_ref, - display_name: summary.display_name.clone(), - display_summary: "External subagent and its requested tools require approval" - .to_string(), - risk_level: ExternalApplicationRiskLevelV2::High, - risk_reason_codes: vec!["delegated_tool_access".to_string()], - recommended: false, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - }, - ); - } - for conflict in catalog - .command_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - { - push_external_application_conflict_review_item( - &mut items, - &mut summary_items, - include_item_details, - conflict.conflict_key.clone(), - || { - ( - format!("Resolve command conflict: {}", conflict.command_name), - "Choose one compatible command source".to_string(), - ) - }, - ); - } - for conflict in catalog - .tool_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - { - push_external_application_conflict_review_item( - &mut items, - &mut summary_items, - include_item_details, - conflict.conflict_key.clone(), - || { - ( - format!("Resolve tool conflict: {}", conflict.tool_name), - "Choose one compatible tool source".to_string(), - ) - }, - ); - } - for conflict in catalog - .mcp_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - { - push_external_application_conflict_review_item( - &mut items, - &mut summary_items, - include_item_details, - conflict.conflict_key.clone(), - || { - ( - format!("Resolve MCP conflict: {}", conflict.server_name), - "Choose one compatible MCP server".to_string(), - ) - }, - ); - } - for conflict in catalog - .subagent_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - { - push_external_application_conflict_review_item( - &mut items, - &mut summary_items, - include_item_details, - conflict.conflict_key.clone(), - || { - ( - format!("Resolve subagent conflict: {}", conflict.logical_id), - "Choose one compatible subagent source".to_string(), - ) - }, - ); - } - items.sort_by(|left, right| left.item_ref.cmp(&right.item_ref)); - items.dedup_by(|left, right| left.item_ref == right.item_ref); - summary_items.sort_by(|left, right| left.item_ref.cmp(&right.item_ref)); - summary_items.dedup_by(|left, right| left.item_ref == right.item_ref); - let expected_generations = vec![ - ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Command, - generation: catalog.generation, - }, - ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Tool, - generation: catalog.generation, - }, - ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Subagent, - generation: catalog.subagent_generation, - }, - ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Mcp, - generation: catalog.mcp_generation, - }, - ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Conflict, - generation: catalog.generation, - }, - ]; - let mut hasher = Sha256::new(); - hasher.update(execution_domain_id.as_bytes()); - hasher.update([0]); - hasher.update(workspace_scope_id.unwrap_or("").as_bytes()); - hasher.update([target_scope as u8]); - hasher.update(preference_revision.to_le_bytes()); - for generation in &expected_generations { - hasher.update([generation.owner as u8]); - hasher.update(generation.generation.to_le_bytes()); - } - for item in &summary_items { - hasher.update([item.item_ref.kind as u8]); - hasher.update(item.item_ref.stable_id.as_bytes()); - hasher.update([0]); - } - let review_id = format!("review:{}", hex::encode(&hasher.finalize()[..16])); - ExternalApplicationReviewPlan { - review_id, - expected_generations, - items, - summary_items, - } -} - -fn external_application_item_failure( - error: &str, -) -> ( - ExternalApplicationOperationOutcomeV2, - String, - Vec, -) { - let code = ExternalSourceOperationError::decode(error) - .map(|error| error.code) - .unwrap_or(ExternalSourceOperationErrorCode::Internal); - match code { - ExternalSourceOperationErrorCode::StaleRevision => ( - ExternalApplicationOperationOutcomeV2::Stale, - code.as_str().to_string(), - vec![ExternalApplicationRecoveryActionV2::Refresh], - ), - ExternalSourceOperationErrorCode::Conflict - | ExternalSourceOperationErrorCode::PolicyLimited - | ExternalSourceOperationErrorCode::TrustRequired - | ExternalSourceOperationErrorCode::Unavailable - | ExternalSourceOperationErrorCode::RuntimeUnavailable => ( - ExternalApplicationOperationOutcomeV2::Blocked, - code.as_str().to_string(), - vec![ExternalApplicationRecoveryActionV2::ViewReason], - ), - ExternalSourceOperationErrorCode::InvalidRequest - | ExternalSourceOperationErrorCode::NotFound => ( - ExternalApplicationOperationOutcomeV2::Rejected, - code.as_str().to_string(), - vec![ExternalApplicationRecoveryActionV2::Refresh], - ), - _ => ( - ExternalApplicationOperationOutcomeV2::Failed, - code.as_str().to_string(), - vec![ExternalApplicationRecoveryActionV2::Retry], - ), - } -} - -fn project_external_application_v2( - catalog: &ExternalSourceCatalogSnapshot, - preferences: &ExternalSourcesConfig, - execution_domain_id: &str, - workspace_scope_id: Option<&str>, - registration: ExternalEcosystemRegistration, - host_capabilities: ExternalApplicationHostCapabilitiesV2, - source_ecosystems: &BTreeMap, - subagents_by_candidate_id: &BTreeMap<&str, &ExternalSubagentSummary>, -) -> ExternalApplicationSummaryV2 { - let ecosystem_id = ®istration.descriptor.ecosystem_id; - let sources = catalog - .sources - .iter() - .filter(|source| { - source.record.ecosystem_id == *ecosystem_id - && !matches!(source.lifecycle, ExternalSourceLifecycleState::Removed) - }) - .collect::>(); - let discovery = if sources.is_empty() { - ExternalApplicationDiscoveryStateV2::NotDiscovered - } else { - ExternalApplicationDiscoveryStateV2::Discovered - }; - let unavailable_sources = sources - .iter() - .filter(|source| matches!(source.lifecycle, ExternalSourceLifecycleState::Unavailable)) - .count(); - let degraded = sources.iter().any(|source| { - matches!( - source.lifecycle, - ExternalSourceLifecycleState::Degraded - | ExternalSourceLifecycleState::Restricted - | ExternalSourceLifecycleState::UsingLastValidVersion - ) || !source.record.diagnostics.is_empty() - }); - let health = if !sources.is_empty() && unavailable_sources == sources.len() { - ExternalApplicationHealthV2::Unavailable - } else if degraded || unavailable_sources > 0 { - ExternalApplicationHealthV2::Degraded - } else { - ExternalApplicationHealthV2::Healthy - }; - let explicit = workspace_scope_id - .and_then(|workspace_scope_id| { - preferences - .application_connections - .get(&external_application_connection_key( - execution_domain_id, - ecosystem_id.as_str(), - Some(workspace_scope_id), - )) - }) - .or_else(|| { - preferences - .application_connections - .get(&external_application_connection_key( - execution_domain_id, - ecosystem_id.as_str(), - None, - )) - }); - let (desired_connection, user_decision) = match explicit { - Some(decision) => ( - public_desired_connection(decision.desired_connection), - public_user_decision(decision.desired_connection), - ), - None => ( - match registration.default_connection_policy { - ExternalApplicationDefaultConnectionPolicyV2::Connect => { - ExternalApplicationDesiredConnectionV2::Connected - } - ExternalApplicationDefaultConnectionPolicyV2::DiscoverOnly - | ExternalApplicationDefaultConnectionPolicyV2::Unsupported => { - ExternalApplicationDesiredConnectionV2::Disconnected - } - }, - ExternalApplicationUserDecisionV2::None, - ), - }; - let connection = if desired_connection == ExternalApplicationDesiredConnectionV2::Connected - && discovery == ExternalApplicationDiscoveryStateV2::Discovered - { - ExternalApplicationConnectionStateV2::Connected - } else { - ExternalApplicationConnectionStateV2::Disconnected - }; - let counts = external_application_counts( - catalog, - ecosystem_id, - source_ecosystems, - subagents_by_candidate_id, - ); - let needs_attention = desired_connection == ExternalApplicationDesiredConnectionV2::NeedsReview - || (connection == ExternalApplicationConnectionStateV2::Connected - && (counts.pending_review > 0 || counts.conflicts > 0)); - let temporarily_unavailable = discovery == ExternalApplicationDiscoveryStateV2::Discovered - && health == ExternalApplicationHealthV2::Unavailable; - let (effective_status, primary_action) = derive_external_application_status_v2( - needs_attention, - temporarily_unavailable, - host_capabilities.can_refresh, - connection, - discovery, - ); - let recovery_actions = match primary_action { - ExternalApplicationPrimaryActionV2::Review => { - vec![ExternalApplicationRecoveryActionV2::Review] - } - ExternalApplicationPrimaryActionV2::Retry => { - vec![ExternalApplicationRecoveryActionV2::Retry] - } - ExternalApplicationPrimaryActionV2::ViewReason => { - vec![ExternalApplicationRecoveryActionV2::ViewReason] - } - _ => Vec::new(), - }; - let acknowledged = preferences - .acknowledged_ecosystems - .contains(&acknowledged_ecosystem_key( - execution_domain_id, - ecosystem_id.as_str(), - )); - ExternalApplicationSummaryV2 { - application_id: ecosystem_id.to_string(), - ecosystem_id: ecosystem_id.to_string(), - display_name: registration.descriptor.display_name, - discovery, - connection, - desired_connection, - health, - effective_status, - primary_action, - default_connection_policy: registration.default_connection_policy, - default_connection_reason: registration.default_connection_reason.to_string(), - enabled_count: counts.enabled, - pending_review_count: counts.pending_review, - blocked_count: counts.blocked, - conflict_count: counts.conflicts, - risk_summary: ExternalApplicationRiskSummaryV2 { - highest_level: (counts.pending_review > 0 || counts.conflicts > 0) - .then_some(ExternalApplicationRiskLevelV2::High), - reason_codes: (counts.pending_review > 0 || counts.conflicts > 0) - .then(|| vec!["executable_content_requires_review".to_string()]) - .unwrap_or_default(), - }, - notice_key: (!acknowledged && discovery == ExternalApplicationDiscoveryStateV2::Discovered) - .then(|| { - format!( - "application_discovered:{}:{}", - ecosystem_id, registration.descriptor.adapter_revision - ) - }), - user_decision, - recovery_actions, - } -} - -fn public_desired_connection( - desired: StoredExternalApplicationDesiredConnection, -) -> ExternalApplicationDesiredConnectionV2 { - match desired { - StoredExternalApplicationDesiredConnection::Connected => { - ExternalApplicationDesiredConnectionV2::Connected - } - StoredExternalApplicationDesiredConnection::Disconnected => { - ExternalApplicationDesiredConnectionV2::Disconnected - } - StoredExternalApplicationDesiredConnection::Deferred => { - ExternalApplicationDesiredConnectionV2::Deferred - } - StoredExternalApplicationDesiredConnection::NeedsReview => { - ExternalApplicationDesiredConnectionV2::NeedsReview - } - } -} - -fn public_user_decision( - desired: StoredExternalApplicationDesiredConnection, -) -> ExternalApplicationUserDecisionV2 { - match desired { - StoredExternalApplicationDesiredConnection::Connected => { - ExternalApplicationUserDecisionV2::Connected - } - StoredExternalApplicationDesiredConnection::Disconnected => { - ExternalApplicationUserDecisionV2::Disconnected - } - StoredExternalApplicationDesiredConnection::Deferred => { - ExternalApplicationUserDecisionV2::Deferred - } - StoredExternalApplicationDesiredConnection::NeedsReview => { - ExternalApplicationUserDecisionV2::NeedsReview - } - } -} - -fn external_application_counts( - catalog: &ExternalSourceCatalogSnapshot, - ecosystem_id: &EcosystemId, - source_ecosystems: &BTreeMap, - subagents_by_candidate_id: &BTreeMap<&str, &ExternalSubagentSummary>, -) -> ExternalApplicationAggregateCounts { - let source_belongs = |source_key: &SourceKey| { - source_ecosystems - .get(source_key) - .is_some_and(|source_ecosystem| source_ecosystem == ecosystem_id) - }; - let mut counts = ExternalApplicationAggregateCounts::default(); - for command in &catalog.commands { - if !source_belongs(&command.definition.id.source) { - continue; - } - match command.definition.availability { - PromptCommandAvailability::Available => counts.enabled += 1, - PromptCommandAvailability::Restricted { .. } - | PromptCommandAvailability::Invalid { .. } => counts.blocked += 1, - _ => counts.blocked += 1, - } - } - for tool in &catalog.tools { - if !source_belongs(&tool.definition.id.target.source) { - continue; - } - match tool.activation { - ExternalToolActivationState::Active => counts.enabled += 1, - ExternalToolActivationState::ApprovalRequired => counts.pending_review += 1, - ExternalToolActivationState::Conflict => {} - ExternalToolActivationState::Unsupported { .. } - | ExternalToolActivationState::RuntimeUnavailable { .. } - | ExternalToolActivationState::LoadFailed { .. } => counts.blocked += 1, - ExternalToolActivationState::Declined | ExternalToolActivationState::Disabled => {} - _ => counts.blocked += 1, - } - } - for subagent in &catalog.subagents { - if !subagent.source_keys.iter().any(source_belongs) { - continue; - } - match subagent.activation_state { - ExternalSubagentActivationState::Active => counts.enabled += 1, - ExternalSubagentActivationState::ApprovalRequired => counts.pending_review += 1, - ExternalSubagentActivationState::Conflict => {} - ExternalSubagentActivationState::Blocked - | ExternalSubagentActivationState::Unavailable => counts.blocked += 1, - ExternalSubagentActivationState::Declined - | ExternalSubagentActivationState::Disabled => {} - } - } - for server in &catalog.mcp_servers { - if !source_belongs(&server.definition.id.source) { - continue; - } - match server.activation_state { - ExternalMcpActivationState::Active => counts.enabled += 1, - ExternalMcpActivationState::ApprovalRequired - | ExternalMcpActivationState::ConfigurationChanged => counts.pending_review += 1, - ExternalMcpActivationState::Conflict => {} - ExternalMcpActivationState::Unsupported { .. } - | ExternalMcpActivationState::RuntimeUnavailable { .. } - | ExternalMcpActivationState::Removed => counts.blocked += 1, - ExternalMcpActivationState::Starting => counts.enabled += 1, - ExternalMcpActivationState::Declined - | ExternalMcpActivationState::Covered { .. } - | ExternalMcpActivationState::SourceDisabled => {} - _ => counts.blocked += 1, - } - } - counts.conflicts += catalog - .command_conflicts - .iter() - .filter(|conflict| { - conflict.selected_candidate_id.is_none() - && conflict - .candidates - .iter() - .any(|candidate| candidate.ecosystem_id == *ecosystem_id) - }) - .count(); - counts.conflicts += catalog - .tool_conflicts - .iter() - .filter(|conflict| { - conflict.selected_candidate_id.is_none() - && conflict - .candidates - .iter() - .any(|candidate| candidate.source.as_ref().is_some_and(&source_belongs)) - }) - .count(); - counts.conflicts += catalog - .mcp_conflicts - .iter() - .filter(|conflict| { - conflict.selected_candidate_id.is_none() - && conflict - .candidates - .iter() - .any(|candidate| candidate.source.as_ref().is_some_and(&source_belongs)) - }) - .count(); - counts.conflicts += catalog - .subagent_conflicts - .iter() - .filter(|conflict| { - conflict.selected_candidate_id.is_none() - && conflict.candidates.iter().any(|candidate| { - subagents_by_candidate_id - .get(candidate.candidate_id.as_str()) - .is_some_and(|subagent| subagent.source_keys.iter().any(&source_belongs)) - }) - }) - .count(); - counts -} - -fn lock_coordinator( - control_plane: &ExternalSourceControlPlane, -) -> MutexGuard<'_, bitfun_external_sources::ExternalSourceCoordinator> { - control_plane.lock_commands() -} - -fn lock_tool_coordinator( - control_plane: &ExternalSourceControlPlane, -) -> MutexGuard<'_, bitfun_external_sources::ExternalToolCoordinator> { - control_plane.lock_tools() -} - -fn lock_subagent_coordinator( - control_plane: &ExternalSourceControlPlane, -) -> MutexGuard<'_, bitfun_external_sources::ExternalSubagentCoordinator> { - control_plane.lock_subagents() -} - -fn lock_mcp_coordinator( - control_plane: &ExternalSourceControlPlane, -) -> MutexGuard<'_, bitfun_external_sources::ExternalMcpCoordinator> { - control_plane.lock_mcp() -} - -fn lock_workspace_reference_coordinator( - control_plane: &ExternalSourceControlPlane, -) -> MutexGuard<'_, bitfun_external_sources::ExternalWorkspaceReferenceCoordinator> { - control_plane.lock_workspace_references() -} - -fn lock_snapshot( - snapshot: &StdMutex, -) -> MutexGuard<'_, ExternalSourceCatalogSnapshot> { - match snapshot.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - } -} - -static WORKSPACE_SERVICES: OnceLock< - DashMap, Weak>, -> = OnceLock::new(); -static READ_ONLY_WORKSPACE_SERVICES: OnceLock< - DashMap, Weak>, -> = OnceLock::new(); -static SAFE_MODE_WORKSPACES: OnceLock> = OnceLock::new(); - -fn safe_mode_workspaces() -> &'static DashMap { - SAFE_MODE_WORKSPACES.get_or_init(DashMap::new) -} -static TOOL_REGISTRY_CHANGE_EPOCH: AtomicU64 = AtomicU64::new(0); -static TOOL_REGISTRY_REBUILD_SCHEDULED: AtomicBool = AtomicBool::new(false); - -fn workspace_services() -> &'static DashMap, Weak> { - WORKSPACE_SERVICES.get_or_init(DashMap::new) -} - -fn read_only_workspace_services( -) -> &'static DashMap, Weak> { - READ_ONLY_WORKSPACE_SERVICES.get_or_init(DashMap::new) -} - -fn workspace_services_for_profile( - profile: ExternalSourceServiceProfile, -) -> &'static DashMap, Weak> { - match profile { - ExternalSourceServiceProfile::LocalExecution => workspace_services(), - ExternalSourceServiceProfile::ReadOnlyProjection => read_only_workspace_services(), - } -} - -fn workspace_service_gate() -> &'static tokio::sync::Mutex<()> { - static GATE: OnceLock> = OnceLock::new(); - GATE.get_or_init(|| tokio::sync::Mutex::new(())) -} - -pub(crate) fn normalize_workspace_root( - workspace_root: Option<&Path>, -) -> Result, String> { - let Some(workspace_root) = workspace_root else { - return Ok(None); - }; - if !workspace_root.is_absolute() { - return Err("external source workspace root must be absolute".to_string()); +pub(crate) fn normalize_workspace_root( + workspace_root: Option<&Path>, +) -> Result, String> { + let Some(workspace_root) = workspace_root else { + return Ok(None); + }; + if !workspace_root.is_absolute() { + return Err("external source workspace root must be absolute".to_string()); } Ok(Some( crate::agentic::workspace::canonical_local_workspace_path(workspace_root), @@ -6209,24 +4642,6 @@ async fn service_for_profile( Ok(service) } -async fn existing_service_for_profile( - workspace_root: Option<&Path>, - profile: ExternalSourceServiceProfile, -) -> Result, String> { - let workspace_root = normalize_workspace_root(workspace_root)?; - let _service_gate = workspace_service_gate().lock().await; - let service = workspace_services_for_profile(profile) - .get(&workspace_root) - .and_then(|service| service.value().upgrade()) - .ok_or_else(|| { - stale_operation_error( - "External application snapshot expired; refresh before continuing", - ) - })?; - service.touch(); - Ok(service) -} - fn epoch_seconds() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -7167,27 +5582,6 @@ fn apply_integration_policy_mutation_to_config( .user_defaults .enabled = false; config.integration_policy = reset_policy; - config.config_origin = Some(ExternalSourcesConfigOrigin::IncompatibleReset); - config.connection_schema_migration_version = - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION; - config.application_connections = default_external_integration_registry() - .into_iter() - .map(|registration| { - ( - external_application_connection_key( - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - registration.descriptor.ecosystem_id.as_str(), - None, - ), - StoredExternalApplicationConnectionDecision { - desired_connection: - StoredExternalApplicationDesiredConnection::Disconnected, - decision_origin: - StoredExternalApplicationDecisionOrigin::IncompatibleReset, - }, - ) - }) - .collect(); config.preference_revision = config.preference_revision.saturating_add(1); return Ok(true); } @@ -8321,60 +6715,7 @@ pub async fn get_external_source_control_snapshot( Ok(service.surface_snapshot(host_capabilities)) } -pub async fn get_external_application_snapshot_v2( - workspace_root: Option<&Path>, - force_refresh: bool, - host_capabilities: ExternalApplicationHostCapabilitiesV2, -) -> Result { - if !host_capabilities.can_read_snapshot { - return Err(unavailable_operation_error( - "This Host cannot read external application state", - )); - } - let service = if host_capabilities.can_mutate { - service_for(workspace_root).await? - } else { - read_only_service_for(workspace_root).await? - }; - if force_refresh { - if host_capabilities.can_refresh && host_capabilities.can_mutate { - service.refresh_with_runtime_invalidation().await?; - } else { - service.refresh().await?; - } - } else { - service.ensure_background_refresh(); - } - let preferences = ExternalSourcePreferenceStore::global()? - .ensure_application_connection_schema(service.execution_domain_id.as_str()) - .await?; - service.application_snapshot_v2(&preferences, host_capabilities) -} - -pub async fn get_external_application_review_page_v2( - workspace_root: Option<&Path>, - request: ExternalApplicationReviewPageRequestV2, -) -> Result { - let service = - existing_service_for_profile(workspace_root, ExternalSourceServiceProfile::LocalExecution) - .await?; - let preferences = ExternalSourcePreferenceStore::global()? - .ensure_application_connection_schema(service.execution_domain_id.as_str()) - .await?; - service.application_review_page_v2(&preferences, request) -} - -pub async fn apply_external_application_action_v2( - workspace_root: Option<&Path>, - request: ExternalApplicationControlRequestV2, -) -> Result { - service_for(workspace_root) - .await? - .apply_application_action_v2(request) - .await -} - -pub async fn apply_external_source_control_action( +pub async fn apply_external_source_control_action( workspace_root: Option<&Path>, request: ExternalSourceControlRequestV1, ) -> ExternalSourceOperationResult { @@ -8735,7 +7076,6 @@ mod opencode_local_source_order_tests; mod tests { use super::*; use crate::service::mcp::{ConfigLocation, MCPServerConfig, MCPServerType}; - use bitfun_product_domains::external_source_control::ExternalApplicationEffectiveStatusV2; use bitfun_product_domains::external_sources::{ EcosystemId, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, PromptCommandAvailability, PromptCommandCatalogEntry, PromptCommandConflict, @@ -10555,561 +8895,6 @@ mod tests { ); } - #[tokio::test] - async fn fresh_v2_store_applies_only_the_product_default_connection() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("external-sources.json"); - let store = ExternalSourcePreferenceStore::new(path); - - let migrated = store - .ensure_application_connection_schema(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .await - .expect("a missing preference file should initialize as fresh v2"); - - assert_eq!( - migrated.connection_schema_migration_version, - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION - ); - assert_eq!( - migrated.config_origin, - Some(ExternalSourcesConfigOrigin::FreshV2) - ); - assert!(migrated.application_connections.is_empty()); - - let policy = integration_policy_snapshot(&migrated, None).unwrap(); - assert!(policy.global_effective.enabled); - assert_eq!( - policy.global_effective.ecosystems[&EcosystemId::new(OPENCODE_ECOSYSTEM_ID).unwrap()] - .mode, - ExternalIntegrationMode::Recommended - ); - for ecosystem_id in [CLAUDE_CODE_ECOSYSTEM_ID, CODEX_ECOSYSTEM_ID] { - assert_eq!( - policy.global_effective.ecosystems[&EcosystemId::new(ecosystem_id).unwrap()].mode, - ExternalIntegrationMode::DiscoverOnly - ); - } - } - - #[tokio::test] - async fn legacy_disabled_policy_migrates_to_explicit_safe_disconnects() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("external-sources.json"); - let store = ExternalSourcePreferenceStore::new(path.clone()); - let mut legacy = ExternalSourcesConfig::default(); - legacy.preference_revision = 7; - legacy - .approved_tool_targets - .insert("preserved-approval".to_string()); - JsonFileStore - .write_atomic_strict(&path, &legacy) - .await - .unwrap(); - - let migrated = store - .ensure_application_connection_schema(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .await - .unwrap(); - - assert_eq!( - migrated.config_origin, - Some(ExternalSourcesConfigOrigin::LegacyMigration) - ); - assert_eq!(migrated.preference_revision, 7); - assert!(migrated - .approved_tool_targets - .contains("preserved-approval")); - for application_id in [ - OPENCODE_ECOSYSTEM_ID, - CLAUDE_CODE_ECOSYSTEM_ID, - CODEX_ECOSYSTEM_ID, - ] { - let key = format!( - "{}\u{1f}{}\u{1f}user_default", - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, application_id - ); - assert_eq!( - migrated.application_connections.get(&key), - Some(&StoredExternalApplicationConnectionDecision { - desired_connection: StoredExternalApplicationDesiredConnection::Disconnected, - decision_origin: StoredExternalApplicationDecisionOrigin::LegacySafety, - }) - ); - } - } - - #[tokio::test] - async fn legacy_enabled_policy_migrates_each_opaque_workspace_scope_independently() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("external-sources.json"); - let store = ExternalSourcePreferenceStore::new(path.clone()); - let workspace_scope_id = "workspace:fedcba9876543210"; - let mut legacy = ExternalSourcesConfig::default(); - let policy = legacy.integration_policy.known_mut().unwrap(); - policy.user_defaults.enabled = true; - policy - .user_defaults - .ecosystems - .entry(EcosystemId::new(CLAUDE_CODE_ECOSYSTEM_ID).unwrap()) - .or_default() - .mode = ExternalIntegrationMode::DiscoverOnly; - let workspace = policy - .workspace_overrides - .entry(workspace_scope_id.to_string()) - .or_default(); - workspace.enabled = Some(true); - workspace - .ecosystems - .entry(EcosystemId::new(OPENCODE_ECOSYSTEM_ID).unwrap()) - .or_default() - .mode = Some(ExternalIntegrationMode::DiscoverOnly); - workspace - .ecosystems - .entry(EcosystemId::new(CODEX_ECOSYSTEM_ID).unwrap()) - .or_default() - .mode = Some(ExternalIntegrationMode::Recommended); - JsonFileStore - .write_atomic_strict(&path, &legacy) - .await - .unwrap(); - - let migrated = store - .ensure_application_connection_schema(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .await - .unwrap(); - let decision = |application_id: &str, scope: Option<&str>| { - migrated.application_connections[&external_application_connection_key( - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - application_id, - scope, - )] - .clone() - }; - assert_eq!( - decision(OPENCODE_ECOSYSTEM_ID, None).desired_connection, - StoredExternalApplicationDesiredConnection::Connected - ); - assert_eq!( - decision(CLAUDE_CODE_ECOSYSTEM_ID, None).desired_connection, - StoredExternalApplicationDesiredConnection::Disconnected - ); - assert_eq!( - decision(OPENCODE_ECOSYSTEM_ID, Some(workspace_scope_id)).desired_connection, - StoredExternalApplicationDesiredConnection::Disconnected - ); - assert_eq!( - decision(CODEX_ECOSYSTEM_ID, Some(workspace_scope_id)).desired_connection, - StoredExternalApplicationDesiredConnection::Connected - ); - } - - #[tokio::test] - async fn future_policy_schema_is_preserved_byte_for_byte_by_the_migration_gate() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("external-sources.json"); - let raw = br#"{ "integrationPolicy": { "schemaMajor": 99, "opaque": [3, 2, 1] }, "future": true }"#; - std::fs::write(&path, raw).unwrap(); - let store = ExternalSourcePreferenceStore::new(path.clone()); - - let error = store - .ensure_application_connection_schema(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .await - .expect_err("future policy schemas must not be migrated"); - - assert!(error.contains("schema major 99")); - assert_eq!(std::fs::read(path).unwrap(), raw); - } - - #[tokio::test] - async fn current_v2_schema_snapshot_gate_does_not_rewrite_preferences() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("external-sources.json"); - let store = ExternalSourcePreferenceStore::new(path.clone()); - let mut current = ExternalSourcesConfig::default(); - current.connection_schema_migration_version = - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION; - current.config_origin = Some(ExternalSourcesConfigOrigin::FreshV2); - current.mcp_revision_secret = Some("00".repeat(32)); - let mut raw = serde_json::to_vec_pretty(¤t).unwrap(); - raw.extend_from_slice(b"\r\n"); - std::fs::write(&path, &raw).unwrap(); - - store - .ensure_application_connection_schema(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .await - .unwrap(); - - assert_eq!(std::fs::read(path).unwrap(), raw); - } - - #[test] - fn legacy_config_with_a_revision_key_remains_available_after_migration_failure() { - let mut legacy = ExternalSourcesConfig::default(); - legacy.mcp_revision_secret = Some("11".repeat(32)); - - let (preserved, revision_key) = legacy_config_after_migration_failure( - legacy.clone(), - "migration write failed".to_string(), - ) - .unwrap(); - - assert_eq!(preserved, legacy); - assert!(!revision_key - .opaque_revision("test", [b"payload".as_slice()]) - .is_empty()); - assert!(legacy_config_after_migration_failure( - ExternalSourcesConfig::default(), - "migration write failed".to_string(), - ) - .unwrap_err() - .contains("migration write failed")); - } - - #[tokio::test] - async fn review_page_does_not_cold_start_an_external_source_service() { - let temp = tempfile::tempdir().unwrap(); - - let error = match existing_service_for_profile( - Some(temp.path()), - ExternalSourceServiceProfile::LocalExecution, - ) - .await - { - Ok(_) => panic!("review page must not cold-start an external source service"), - Err(error) => error, - }; - - assert!(error.contains("stale_revision")); - } - - #[test] - fn application_snapshot_uses_registry_defaults_and_shared_status_priority() { - let service = test_service(Vec::new()); - let source_key = SourceKey::new("opencode.commands", "project").unwrap(); - lock_snapshot(&service.snapshot).sources = vec![ExternalSourceCatalogEntry { - stable_key: source_key.stable_key(), - presentation_group_id: None, - record: ExternalSourceRecord { - key: source_key, - ecosystem_id: EcosystemId::new(OPENCODE_ECOSYSTEM_ID).unwrap(), - display_name: "OpenCode project commands".to_string(), - source_kind: "opencode_commands".to_string(), - scope: ExternalSourceScope::Project, - location: "/.opencode/commands".to_string(), - execution_domain_id: ExecutionDomainId::new(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .unwrap(), - health: bitfun_product_domains::external_sources::ExternalSourceHealth::Available, - content_version: "v1".to_string(), - diagnostics: Vec::new(), - }, - lifecycle: ExternalSourceLifecycleState::Available, - }]; - let mut preferences = ExternalSourcesConfig::default(); - apply_fresh_v2_product_defaults(&mut preferences).unwrap(); - preferences.config_origin = Some(ExternalSourcesConfigOrigin::FreshV2); - preferences.connection_schema_migration_version = - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION; - - let snapshot = service - .application_snapshot_v2( - &preferences, - ExternalApplicationHostCapabilitiesV2::read_write(), - ) - .unwrap(); - - assert_eq!(snapshot.schema_version, EXTERNAL_APPLICATION_SCHEMA_V2); - assert_eq!(snapshot.applications.len(), 3); - let open_code = snapshot - .applications - .iter() - .find(|application| application.application_id == OPENCODE_ECOSYSTEM_ID) - .unwrap(); - assert_eq!( - open_code.default_connection_policy, - ExternalApplicationDefaultConnectionPolicyV2::Connect - ); - assert_eq!( - open_code.user_decision, - ExternalApplicationUserDecisionV2::None - ); - assert_eq!( - open_code.desired_connection, - ExternalApplicationDesiredConnectionV2::Connected - ); - assert_eq!( - open_code.connection, - ExternalApplicationConnectionStateV2::Connected - ); - assert_eq!( - open_code.effective_status, - ExternalApplicationEffectiveStatusV2::Connected - ); - assert_eq!( - open_code.primary_action, - ExternalApplicationPrimaryActionV2::View - ); - for application_id in [CLAUDE_CODE_ECOSYSTEM_ID, CODEX_ECOSYSTEM_ID] { - let application = snapshot - .applications - .iter() - .find(|application| application.application_id == application_id) - .unwrap(); - assert_eq!( - application.default_connection_policy, - ExternalApplicationDefaultConnectionPolicyV2::DiscoverOnly - ); - assert_eq!( - application.effective_status, - ExternalApplicationEffectiveStatusV2::NoConfiguration - ); - } - } - - #[test] - fn workspace_connection_decision_updates_v2_and_v1_projection_together() { - let mut preferences = ExternalSourcesConfig::default(); - apply_fresh_v2_product_defaults(&mut preferences).unwrap(); - preferences.config_origin = Some(ExternalSourcesConfigOrigin::FreshV2); - preferences.connection_schema_migration_version = - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION; - preferences.preference_revision = 3; - let workspace_scope_id = "workspace:0123456789abcdef"; - - assert!(apply_external_application_connection_decision( - &mut preferences, - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - ExternalApplicationTargetScopeV2::WorkspaceOverride, - Some(workspace_scope_id), - CODEX_ECOSYSTEM_ID, - StoredExternalApplicationDesiredConnection::Connected, - 3, - ) - .unwrap()); - - assert_eq!(preferences.preference_revision, 4); - let key = external_application_connection_key( - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - CODEX_ECOSYSTEM_ID, - Some(workspace_scope_id), - ); - assert_eq!( - preferences.application_connections.get(&key), - Some(&StoredExternalApplicationConnectionDecision { - desired_connection: StoredExternalApplicationDesiredConnection::Connected, - decision_origin: StoredExternalApplicationDecisionOrigin::User, - }) - ); - let document = preferences.integration_policy.known().unwrap(); - assert_eq!( - document.user_defaults.ecosystems[&EcosystemId::new(CODEX_ECOSYSTEM_ID).unwrap()].mode, - ExternalIntegrationMode::DiscoverOnly - ); - assert_eq!( - document.workspace_overrides[workspace_scope_id].ecosystems - [&EcosystemId::new(CODEX_ECOSYSTEM_ID).unwrap()] - .mode, - Some(ExternalIntegrationMode::Recommended) - ); - assert!(apply_external_application_connection_decision( - &mut preferences, - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - ExternalApplicationTargetScopeV2::WorkspaceOverride, - Some(workspace_scope_id), - CODEX_ECOSYSTEM_ID, - StoredExternalApplicationDesiredConnection::Disconnected, - 3, - ) - .is_err()); - assert_eq!(preferences.preference_revision, 4); - } - - #[test] - fn application_action_scope_allows_explicit_user_default_from_a_workspace() { - let current_workspace_scope = Some("workspace:0123456789abcdef"); - - assert!(external_application_action_scope_matches( - current_workspace_scope, - ExternalApplicationTargetScopeV2::UserDefault, - None, - )); - assert!(external_application_action_scope_matches( - current_workspace_scope, - ExternalApplicationTargetScopeV2::WorkspaceOverride, - current_workspace_scope, - )); - assert!(!external_application_action_scope_matches( - current_workspace_scope, - ExternalApplicationTargetScopeV2::WorkspaceOverride, - Some("workspace:different"), - )); - assert!(!external_application_action_scope_matches( - None, - ExternalApplicationTargetScopeV2::WorkspaceOverride, - Some("workspace:0123456789abcdef"), - )); - } - - #[test] - fn application_review_page_is_bounded_and_bound_to_owner_generations() { - let service = test_service(Vec::new()); - let source_key = SourceKey::new("opencode.commands", "project").unwrap(); - { - let mut catalog = lock_snapshot(&service.snapshot); - catalog.generation = 7; - catalog.subagent_generation = 3; - catalog.mcp_generation = 5; - catalog.sources = vec![ExternalSourceCatalogEntry { - stable_key: source_key.stable_key(), - presentation_group_id: None, - record: ExternalSourceRecord { - key: source_key.clone(), - ecosystem_id: EcosystemId::new(OPENCODE_ECOSYSTEM_ID).unwrap(), - display_name: "OpenCode project commands".to_string(), - source_kind: "opencode_commands".to_string(), - scope: ExternalSourceScope::Project, - location: "/.opencode/commands".to_string(), - execution_domain_id: ExecutionDomainId::new(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .unwrap(), - health: - bitfun_product_domains::external_sources::ExternalSourceHealth::Available, - content_version: "v1".to_string(), - diagnostics: Vec::new(), - }, - lifecycle: ExternalSourceLifecycleState::Available, - }]; - catalog.command_conflicts = vec![PromptCommandConflict { - conflict_key: "prompt-command-conflict".to_string(), - command_name: "review".to_string(), - candidates: vec![PromptCommandConflictCandidate { - candidate_id: "opencode.commands:project:review".to_string(), - source: source_key, - source_display_name: "OpenCode".to_string(), - ecosystem_id: EcosystemId::new(OPENCODE_ECOSYSTEM_ID).unwrap(), - content_version: "command-v1".to_string(), - command_description: "Review changes".to_string(), - source_scope: ExternalSourceScope::Project, - source_location: "/.opencode/commands/review.md".to_string(), - execution_target: PromptCommandExecutionTarget::Inline, - availability: PromptCommandAvailability::Available, - }], - selected_candidate_id: None, - }]; - } - let mut preferences = ExternalSourcesConfig::default(); - apply_fresh_v2_product_defaults(&mut preferences).unwrap(); - preferences.config_origin = Some(ExternalSourcesConfigOrigin::FreshV2); - preferences.connection_schema_migration_version = - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION; - preferences.preference_revision = 11; - - let snapshot = service - .application_snapshot_v2( - &preferences, - ExternalApplicationHostCapabilitiesV2::read_write(), - ) - .unwrap(); - let review = snapshot - .review_summary - .expect("unresolved conflict requires review"); - assert_eq!(review.total_count, 1); - let initial_review_id = review.review_id.clone(); - let request = ExternalApplicationReviewPageRequestV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: service.execution_domain_id.clone(), - workspace_scope_id: None, - target_scope: ExternalApplicationTargetScopeV2::UserDefault, - review_id: review.review_id, - preference_revision: 11, - expected_generations: Vec::new(), - cursor: None, - page_size: EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, - }; - lock_snapshot(&service.snapshot).generation += 1; - let mut unbound_follow_up = request.clone(); - unbound_follow_up.cursor = Some(format!("{}:0", unbound_follow_up.review_id)); - assert!(service - .application_review_page_v2(&preferences, unbound_follow_up) - .unwrap_err() - .contains("stale_revision")); - let page = service - .application_review_page_v2(&preferences, request) - .unwrap(); - assert_ne!(page.review_id, initial_review_id); - assert_eq!(page.items.len(), 1); - assert_eq!( - page.items[0].item_ref.kind, - ExternalApplicationReviewItemKindV2::Conflict - ); - assert!(!page.items[0].display_summary.contains(".opencode")); - assert_eq!(page.expected_generations.len(), 5); - } - - #[test] - fn application_review_plan_binds_pending_subagent_candidate_to_its_decision() { - let service = test_service(Vec::new()); - let candidate_id = "opencode.subagents:project:reviewer"; - let decision_key = "subagent-approval:reviewer"; - let catalog = { - let mut catalog = lock_snapshot(&service.snapshot); - catalog.subagent_generation = 4; - catalog.subagents = vec![ExternalSubagentSummary { - candidate_id: candidate_id.to_string(), - logical_id: "reviewer".to_string(), - display_name: "Code reviewer".to_string(), - description: "Reviews the current change".to_string(), - provider_label: "OpenCode".to_string(), - scope: ExternalSourceScope::Project, - source_keys: Vec::new(), - source_location_labels: Vec::new(), - source_count: 1, - mode: Default::default(), - requested_model: Default::default(), - requested_model_profile: None, - model_binding_method: Default::default(), - model_binding_key: None, - effective_model_label: None, - effective_tool_labels: vec!["read".to_string()], - unavailable_tool_labels: Vec::new(), - supports_follow_up: false, - compatibility_state: ExternalSubagentCompatibilityState::Ready, - diagnostics: Vec::new(), - activation_state: ExternalSubagentActivationState::ApprovalRequired, - decision_key: decision_key.to_string(), - }]; - catalog.pending_subagent_approvals = vec![candidate_id.to_string()]; - catalog.clone() - }; - - let plan = external_application_review_plan( - &catalog, - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - None, - ExternalApplicationTargetScopeV2::UserDefault, - 0, - ); - - assert_eq!(plan.items.len(), 1); - assert_eq!(plan.items[0].display_name, "Code reviewer"); - assert_eq!(plan.items[0].item_ref.stable_id, decision_key); - - let subagents_by_candidate_id = catalog - .subagents - .iter() - .map(|subagent| (subagent.candidate_id.as_str(), subagent)) - .collect::>(); - let summary_only = external_application_review_plan_internal( - &catalog, - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - None, - ExternalApplicationTargetScopeV2::UserDefault, - 0, - &subagents_by_candidate_id, - false, - ); - assert!(summary_only.items.is_empty()); - assert_eq!(summary_only.summary(), plan.summary()); - } - #[tokio::test] async fn acknowledging_an_ecosystem_survives_a_reload_and_stays_idempotent() { let temp = tempfile::tempdir().unwrap(); @@ -11474,6 +9259,189 @@ mod tests { assert_eq!(encoded["futurePreferenceField"][0], "keep"); } + fn retired_application_default_fixture() -> ExternalSourcesConfig { + let mut config = ExternalSourcesConfig::default(); + let policy = config + .integration_policy + .known_mut() + .expect("the built-in integration policy is known"); + policy.user_defaults.enabled = true; + for (ecosystem, mode) in [ + (OPENCODE_ECOSYSTEM_ID, ExternalIntegrationMode::Recommended), + ( + CLAUDE_CODE_ECOSYSTEM_ID, + ExternalIntegrationMode::DiscoverOnly, + ), + (CODEX_ECOSYSTEM_ID, ExternalIntegrationMode::DiscoverOnly), + ] { + policy + .user_defaults + .ecosystems + .entry(EcosystemId::new(ecosystem).unwrap()) + .or_default() + .mode = mode; + } + config + } + + fn retired_application_document( + config: ExternalSourcesConfig, + decisions: serde_json::Value, + ) -> serde_json::Value { + let mut raw = serde_json::to_value(config).unwrap(); + raw["configOrigin"] = serde_json::json!("fresh_v2"); + raw["connectionSchemaMigrationVersion"] = serde_json::json!(1); + raw["applicationConnections"] = decisions; + raw + } + + #[tokio::test] + async fn retired_automatic_application_default_is_not_user_consent() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + let raw = retired_application_document( + retired_application_default_fixture(), + serde_json::json!({}), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + let store = ExternalSourcePreferenceStore::new(path); + + let read = store.read().await.unwrap(); + let read_policy = read.integration_policy.known().unwrap(); + assert!(!read_policy.user_defaults.enabled); + assert!(read_policy.user_defaults.ecosystems.is_empty()); + assert!(read.extensions.contains_key("applicationConnections")); + + let (was_enabled, updated) = store + .update(|config| { + config + .integration_policy + .known() + .unwrap() + .user_defaults + .enabled + }) + .await + .unwrap(); + assert!(!was_enabled); + assert!( + !updated + .integration_policy + .known() + .unwrap() + .user_defaults + .enabled + ); + } + + #[tokio::test] + async fn retired_migration_is_consumed_before_later_user_policy_changes() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + let mut config = retired_application_default_fixture(); + config + .integration_policy + .known_mut() + .unwrap() + .user_defaults + .ecosystems + .get_mut(&EcosystemId::new(CLAUDE_CODE_ECOSYSTEM_ID).unwrap()) + .unwrap() + .mode = ExternalIntegrationMode::Disabled; + let raw = retired_application_document(config, serde_json::json!({})); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + let store = ExternalSourcePreferenceStore::new(path); + + let (_, migrated) = store.update(|_| {}).await.unwrap(); + assert!(!migrated.extensions.contains_key("configOrigin")); + assert_eq!( + migrated + .integration_policy + .known() + .unwrap() + .user_defaults + .ecosystems[&EcosystemId::new(CLAUDE_CODE_ECOSYSTEM_ID).unwrap()] + .mode, + ExternalIntegrationMode::Disabled + ); + + store + .update(|config| { + config.integration_policy = + StoredExternalIntegrationPolicy::Known(retired_automatic_application_policy()); + }) + .await + .unwrap(); + + let read = store.read().await.unwrap(); + assert_eq!( + read.integration_policy.known(), + Some(&retired_automatic_application_policy()) + ); + } + + #[tokio::test] + async fn retired_application_metadata_preserves_a_policy_user_deviation() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + let mut config = retired_application_default_fixture(); + config + .integration_policy + .known_mut() + .unwrap() + .user_defaults + .ecosystems + .get_mut(&EcosystemId::new(CLAUDE_CODE_ECOSYSTEM_ID).unwrap()) + .unwrap() + .mode = ExternalIntegrationMode::Disabled; + let raw = retired_application_document(config, serde_json::json!({})); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let config = ExternalSourcePreferenceStore::new(path) + .read() + .await + .unwrap(); + let policy = config.integration_policy.known().unwrap(); + + assert!(policy.user_defaults.enabled); + assert_eq!( + policy.user_defaults.ecosystems[&EcosystemId::new(CLAUDE_CODE_ECOSYSTEM_ID).unwrap()] + .mode, + ExternalIntegrationMode::Disabled + ); + } + + #[tokio::test] + async fn retired_application_metadata_preserves_an_explicit_application_choice() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + let raw = retired_application_document( + retired_application_default_fixture(), + serde_json::json!({ + "local-user\u{1f}opencode\u{1f}user_default": { + "desiredConnection": "connected", + "decisionOrigin": "user" + } + }), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let config = ExternalSourcePreferenceStore::new(path) + .read() + .await + .unwrap(); + + assert!( + config + .integration_policy + .known() + .unwrap() + .user_defaults + .enabled + ); + assert!(config.extensions.contains_key("applicationConnections")); + } + #[test] fn incompatible_policy_requires_explicit_reset_and_keeps_a_bounded_backup() { let future_policy = serde_json::json!({ @@ -11560,32 +9528,6 @@ mod tests { vec![11, 12, 13] ); assert_eq!(config.integration_policy_backups[2], future_policy); - assert_eq!( - config.config_origin, - Some(ExternalSourcesConfigOrigin::IncompatibleReset) - ); - assert_eq!( - config.connection_schema_migration_version, - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION - ); - for application_id in [ - OPENCODE_ECOSYSTEM_ID, - CLAUDE_CODE_ECOSYSTEM_ID, - CODEX_ECOSYSTEM_ID, - ] { - let key = external_application_connection_key( - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - application_id, - None, - ); - assert_eq!( - config.application_connections.get(&key), - Some(&StoredExternalApplicationConnectionDecision { - desired_connection: StoredExternalApplicationDesiredConnection::Disconnected, - decision_origin: StoredExternalApplicationDecisionOrigin::IncompatibleReset, - }) - ); - } } #[tokio::test] diff --git a/src/crates/contracts/product-domains/src/external_source_control.rs b/src/crates/contracts/product-domains/src/external_source_control.rs index 721a0866a..3f1905107 100644 --- a/src/crates/contracts/product-domains/src/external_source_control.rs +++ b/src/crates/contracts/product-domains/src/external_source_control.rs @@ -13,8 +13,6 @@ use crate::external_subagents::ExternalSubagentActivationState; use serde::{Deserialize, Serialize}; pub const EXTERNAL_SOURCE_CONTROL_SCHEMA_V1: u32 = 1; -pub const EXTERNAL_APPLICATION_SCHEMA_V2: u32 = 2; -pub const EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS: usize = 128; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -211,637 +209,6 @@ impl ExternalSourceControlRequestV1 { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationTargetScopeV2 { - UserDefault, - WorkspaceOverride, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationDesiredConnectionV2 { - Unspecified, - Connected, - Disconnected, - Deferred, - NeedsReview, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationUserDecisionV2 { - None, - Connected, - Disconnected, - Deferred, - NeedsReview, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationDiscoveryStateV2 { - NotDiscovered, - Discovered, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationConnectionStateV2 { - Disconnected, - Connected, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationHealthV2 { - Healthy, - Degraded, - Unavailable, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationEffectiveStatusV2 { - Connected, - ConfigurationAvailable, - NoConfiguration, - NeedsAttention, - TemporarilyUnavailable, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationPrimaryActionV2 { - None, - View, - Connect, - Review, - Retry, - ViewReason, -} - -/// Derives the shared application summary and its single emphasized action. -/// Safe Mode is projected separately and intentionally is not an input. -pub const fn derive_external_application_status_v2( - needs_attention: bool, - temporarily_unavailable: bool, - can_retry: bool, - connection: ExternalApplicationConnectionStateV2, - discovery: ExternalApplicationDiscoveryStateV2, -) -> ( - ExternalApplicationEffectiveStatusV2, - ExternalApplicationPrimaryActionV2, -) { - if needs_attention { - ( - ExternalApplicationEffectiveStatusV2::NeedsAttention, - ExternalApplicationPrimaryActionV2::Review, - ) - } else if temporarily_unavailable { - ( - ExternalApplicationEffectiveStatusV2::TemporarilyUnavailable, - if can_retry { - ExternalApplicationPrimaryActionV2::Retry - } else { - ExternalApplicationPrimaryActionV2::ViewReason - }, - ) - } else if matches!(connection, ExternalApplicationConnectionStateV2::Connected) { - ( - ExternalApplicationEffectiveStatusV2::Connected, - ExternalApplicationPrimaryActionV2::View, - ) - } else if matches!(discovery, ExternalApplicationDiscoveryStateV2::Discovered) { - ( - ExternalApplicationEffectiveStatusV2::ConfigurationAvailable, - ExternalApplicationPrimaryActionV2::Connect, - ) - } else { - ( - ExternalApplicationEffectiveStatusV2::NoConfiguration, - ExternalApplicationPrimaryActionV2::None, - ) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationDefaultConnectionPolicyV2 { - Connect, - DiscoverOnly, - Unsupported, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationRiskLevelV2 { - Low, - Moderate, - High, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationSafetyCeilingV2 { - Blocked, - ReviewRequired, - Automatic, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -pub enum ExternalApplicationRecoveryActionV2 { - Refresh, - Retry, - ReconnectHost, - Review, - UpgradeHost, - ViewReason, - ExitSafeMode, - ResolveConflict, - InstallRuntime, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationHostCapabilitiesV2 { - pub can_read_snapshot: bool, - pub can_read_review: bool, - pub can_mutate: bool, - pub can_manage_user_default: bool, - pub can_manage_workspace_override: bool, - pub can_refresh: bool, - pub can_set_safe_mode: bool, -} - -impl ExternalApplicationHostCapabilitiesV2 { - pub const fn read_write() -> Self { - Self { - can_read_snapshot: true, - can_read_review: true, - can_mutate: true, - can_manage_user_default: true, - can_manage_workspace_override: true, - can_refresh: true, - can_set_safe_mode: true, - } - } - - pub const fn read_only() -> Self { - Self { - can_read_snapshot: true, - can_read_review: true, - can_mutate: false, - can_manage_user_default: false, - can_manage_workspace_override: false, - can_refresh: true, - can_set_safe_mode: false, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationRiskSummaryV2 { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub highest_level: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub reason_codes: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationReviewItemKindV2 { - Command, - Tool, - Subagent, - Mcp, - Conflict, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewItemRefV2 { - pub kind: ExternalApplicationReviewItemKindV2, - pub stable_id: String, -} - -impl ExternalApplicationReviewItemRefV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_reference(&self.stable_id) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationOwnerGenerationV2 { - pub owner: ExternalApplicationReviewItemKindV2, - pub generation: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewCategoryCountV2 { - pub kind: ExternalApplicationReviewItemKindV2, - pub count: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewRecommendationSummaryV2 { - pub recommended_count: usize, - pub optional_count: usize, - pub blocked_count: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewSummaryV2 { - pub review_id: String, - pub total_count: usize, - pub category_counts: Vec, - pub max_selection_count: usize, - pub risk_summary: ExternalApplicationRiskSummaryV2, - pub recommendation_summary: ExternalApplicationReviewRecommendationSummaryV2, - pub safety_ceiling: ExternalApplicationSafetyCeilingV2, -} - -impl ExternalApplicationReviewSummaryV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_id(&self.review_id)?; - if self.max_selection_count > self.total_count { - return Err("external application max selection count exceeds total count"); - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationSummaryV2 { - pub application_id: String, - pub ecosystem_id: String, - pub display_name: String, - pub discovery: ExternalApplicationDiscoveryStateV2, - pub connection: ExternalApplicationConnectionStateV2, - pub desired_connection: ExternalApplicationDesiredConnectionV2, - pub health: ExternalApplicationHealthV2, - pub effective_status: ExternalApplicationEffectiveStatusV2, - pub primary_action: ExternalApplicationPrimaryActionV2, - pub default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2, - pub default_connection_reason: String, - pub enabled_count: usize, - pub pending_review_count: usize, - pub blocked_count: usize, - pub conflict_count: usize, - pub risk_summary: ExternalApplicationRiskSummaryV2, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub notice_key: Option, - pub user_decision: ExternalApplicationUserDecisionV2, - pub recovery_actions: Vec, -} - -impl ExternalApplicationSummaryV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_id(&self.application_id)?; - validate_external_application_id(&self.ecosystem_id)?; - validate_external_application_text(&self.display_name)?; - validate_external_application_id(&self.default_connection_reason)?; - if let Some(notice_key) = &self.notice_key { - validate_external_application_reference(notice_key)?; - } - for reason_code in &self.risk_summary.reason_codes { - validate_external_application_id(reason_code)?; - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationSnapshotV2 { - pub schema_version: u32, - pub execution_domain_id: ExecutionDomainId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_scope_id: Option, - pub effective_connection_scope: ExternalApplicationTargetScopeV2, - pub refresh_generation: u64, - pub preference_revision: u64, - pub safe_mode: bool, - pub host_capabilities: ExternalApplicationHostCapabilitiesV2, - pub applications: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub review_summary: Option, -} - -impl ExternalApplicationSnapshotV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_schema(self.schema_version)?; - if let Some(workspace_scope_id) = &self.workspace_scope_id { - validate_external_application_id(workspace_scope_id)?; - } - for application in &self.applications { - application.validate()?; - } - if let Some(review_summary) = &self.review_summary { - review_summary.validate()?; - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewItemV2 { - pub item_ref: ExternalApplicationReviewItemRefV2, - pub display_name: String, - pub display_summary: String, - pub risk_level: ExternalApplicationRiskLevelV2, - pub risk_reason_codes: Vec, - pub recommended: bool, - pub safety_ceiling: ExternalApplicationSafetyCeilingV2, -} - -impl ExternalApplicationReviewItemV2 { - pub fn validate(&self) -> Result<(), &'static str> { - self.item_ref.validate()?; - validate_external_application_text(&self.display_name)?; - validate_external_application_text(&self.display_summary)?; - for reason_code in &self.risk_reason_codes { - validate_external_application_id(reason_code)?; - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewPageRequestV2 { - pub schema_version: u32, - pub execution_domain_id: ExecutionDomainId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_scope_id: Option, - pub target_scope: ExternalApplicationTargetScopeV2, - pub review_id: String, - pub preference_revision: u64, - pub expected_generations: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cursor: Option, - pub page_size: usize, -} - -impl ExternalApplicationReviewPageRequestV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_schema(self.schema_version)?; - validate_external_application_target_scope(self.target_scope, &self.workspace_scope_id)?; - validate_external_application_id(&self.review_id)?; - if let Some(cursor) = &self.cursor { - validate_external_application_reference(cursor)?; - } - if self.page_size == 0 || self.page_size > EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS { - return Err("external application review page size must be between 1 and 128"); - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewPageV2 { - pub schema_version: u32, - pub execution_domain_id: ExecutionDomainId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_scope_id: Option, - pub target_scope: ExternalApplicationTargetScopeV2, - pub review_id: String, - pub preference_revision: u64, - pub expected_generations: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cursor: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub next_cursor: Option, - pub total_count: usize, - pub items: Vec, -} - -impl ExternalApplicationReviewPageV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_schema(self.schema_version)?; - validate_external_application_target_scope(self.target_scope, &self.workspace_scope_id)?; - validate_external_application_id(&self.review_id)?; - if self.items.len() > EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS { - return Err("external application review page exceeds 128 items"); - } - if self.items.len() > self.total_count { - return Err("external application review page exceeds total count"); - } - if let Some(cursor) = &self.cursor { - validate_external_application_reference(cursor)?; - } - if let Some(cursor) = &self.next_cursor { - validate_external_application_reference(cursor)?; - } - for item in &self.items { - item.validate()?; - } - Ok(()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationReviewSelectionBaselineV2 { - Recommended, - None, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewSelectionOverrideV2 { - pub item_ref: ExternalApplicationReviewItemRefV2, - pub selected: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde( - tag = "type", - rename_all = "snake_case", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub enum ExternalApplicationControlActionV2 { - ConnectApplication { - application_id: String, - }, - DisconnectApplication { - application_id: String, - }, - SetApplicationDeferred { - application_id: String, - }, - SubmitApplicationReview { - review_id: String, - expected_generations: Vec, - selection_baseline: ExternalApplicationReviewSelectionBaselineV2, - selection_overrides: Vec, - }, - Refresh, - SetSourceEnabled { - source_key: String, - enabled: bool, - }, - SetSafeMode { - enabled: bool, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationControlRequestV2 { - pub schema_version: u32, - pub execution_domain_id: ExecutionDomainId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_scope_id: Option, - pub target_scope: ExternalApplicationTargetScopeV2, - pub operation_id: String, - pub expected_preference_revision: u64, - pub action: ExternalApplicationControlActionV2, -} - -impl ExternalApplicationControlRequestV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_schema(self.schema_version)?; - validate_external_application_target_scope(self.target_scope, &self.workspace_scope_id)?; - validate_external_application_id(&self.operation_id)?; - match &self.action { - ExternalApplicationControlActionV2::ConnectApplication { application_id } - | ExternalApplicationControlActionV2::DisconnectApplication { application_id } - | ExternalApplicationControlActionV2::SetApplicationDeferred { application_id } => { - validate_external_application_id(application_id) - } - ExternalApplicationControlActionV2::SubmitApplicationReview { - review_id, - selection_overrides, - .. - } => { - validate_external_application_id(review_id)?; - for selection in selection_overrides { - selection.item_ref.validate()?; - } - Ok(()) - } - ExternalApplicationControlActionV2::SetSourceEnabled { source_key, .. } => { - validate_external_application_reference(source_key) - } - ExternalApplicationControlActionV2::Refresh - | ExternalApplicationControlActionV2::SetSafeMode { .. } => Ok(()), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationOperationOutcomeV2 { - Applied, - Rejected, - Blocked, - Stale, - Failed, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewItemResultV2 { - pub item_ref: ExternalApplicationReviewItemRefV2, - pub outcome: ExternalApplicationOperationOutcomeV2, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason_code: Option, - pub recovery_actions: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationControlResultV2 { - pub schema_version: u32, - pub operation_id: String, - pub preference_revision: u64, - pub outcome: ExternalApplicationOperationOutcomeV2, - pub item_results: Vec, -} - -impl ExternalApplicationControlResultV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_schema(self.schema_version)?; - validate_external_application_id(&self.operation_id)?; - for item in &self.item_results { - item.item_ref.validate()?; - if let Some(reason_code) = &item.reason_code { - validate_external_application_id(reason_code)?; - } - } - Ok(()) - } -} - -fn validate_external_application_schema(schema_version: u32) -> Result<(), &'static str> { - if schema_version == EXTERNAL_APPLICATION_SCHEMA_V2 { - Ok(()) - } else { - Err("unsupported external application schema") - } -} - -fn validate_external_application_target_scope( - target_scope: ExternalApplicationTargetScopeV2, - workspace_scope_id: &Option, -) -> Result<(), &'static str> { - match (target_scope, workspace_scope_id) { - (ExternalApplicationTargetScopeV2::UserDefault, None) => Ok(()), - (ExternalApplicationTargetScopeV2::UserDefault, Some(_)) => { - Err("user-default scope must not include a workspace scope id") - } - (ExternalApplicationTargetScopeV2::WorkspaceOverride, Some(workspace_scope_id)) => { - validate_external_application_id(workspace_scope_id) - } - (ExternalApplicationTargetScopeV2::WorkspaceOverride, None) => { - Err("workspace-override scope requires a workspace scope id") - } - } -} - -fn validate_external_application_id(value: &str) -> Result<(), &'static str> { - if value.is_empty() - || value.len() > 160 - || value.trim() != value - || value.chars().any(char::is_control) - { - Err("invalid external application identifier") - } else { - Ok(()) - } -} - -fn validate_external_application_reference(value: &str) -> Result<(), &'static str> { - if value.is_empty() - || value.len() > 4096 - || value.trim() != value - || value.chars().any(char::is_control) - { - Err("invalid external application reference") - } else { - Ok(()) - } -} - -fn validate_external_application_text(value: &str) -> Result<(), &'static str> { - if value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) { - Err("invalid external application text") - } else { - Ok(()) - } -} - impl ExternalSourceControlSnapshotV1 { pub fn from_catalog( catalog: &ExternalSourceCatalogSnapshot, diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs index 80173dcd1..e9f9cc074 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -6,27 +6,9 @@ use bitfun_product_domains::external_integration_policy::{ ExternalIntegrationPolicyStatus, }; use bitfun_product_domains::external_source_control::{ - derive_external_application_status_v2, ExternalApplicationConnectionStateV2, - ExternalApplicationControlActionV2, ExternalApplicationControlRequestV2, - ExternalApplicationControlResultV2, ExternalApplicationDefaultConnectionPolicyV2, - ExternalApplicationDesiredConnectionV2, ExternalApplicationDiscoveryStateV2, - ExternalApplicationEffectiveStatusV2, ExternalApplicationHealthV2, - ExternalApplicationHostCapabilitiesV2, ExternalApplicationOperationOutcomeV2, - ExternalApplicationOwnerGenerationV2, ExternalApplicationPrimaryActionV2, - ExternalApplicationRecoveryActionV2, ExternalApplicationReviewCategoryCountV2, - ExternalApplicationReviewItemKindV2, ExternalApplicationReviewItemRefV2, - ExternalApplicationReviewItemResultV2, ExternalApplicationReviewItemV2, - ExternalApplicationReviewPageRequestV2, ExternalApplicationReviewPageV2, - ExternalApplicationReviewRecommendationSummaryV2, ExternalApplicationReviewSelectionBaselineV2, - ExternalApplicationReviewSelectionOverrideV2, ExternalApplicationReviewSummaryV2, - ExternalApplicationRiskLevelV2, ExternalApplicationRiskSummaryV2, - ExternalApplicationSafetyCeilingV2, ExternalApplicationSnapshotV2, - ExternalApplicationSummaryV2, ExternalApplicationTargetScopeV2, - ExternalApplicationUserDecisionV2, ExternalSourceControlActionV1, - ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, ExternalSourceDesiredState, - ExternalSourceDiscoveryState, ExternalSourceOperationStage, ExternalSourceRecoveryActionV1, - ExternalSourceReviewState, EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, - EXTERNAL_APPLICATION_SCHEMA_V2, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, + ExternalSourceControlActionV1, ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, + ExternalSourceDesiredState, ExternalSourceDiscoveryState, ExternalSourceOperationStage, + ExternalSourceRecoveryActionV1, ExternalSourceReviewState, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, }; use bitfun_product_domains::external_sources::{ external_mcp_approval_key, external_mcp_conflict_key, external_tool_approval_key, @@ -2090,381 +2072,3 @@ fn decoded_operation_errors_bound_untrusted_extension_fields() { ] ); } - -fn application_risk_summary() -> ExternalApplicationRiskSummaryV2 { - ExternalApplicationRiskSummaryV2 { - highest_level: Some(ExternalApplicationRiskLevelV2::High), - reason_codes: vec!["process_execution".to_string()], - } -} - -fn application_review_summary() -> ExternalApplicationReviewSummaryV2 { - ExternalApplicationReviewSummaryV2 { - review_id: "review-opencode-7".to_string(), - total_count: 3, - category_counts: vec![ExternalApplicationReviewCategoryCountV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - count: 3, - }], - max_selection_count: 3, - risk_summary: application_risk_summary(), - recommendation_summary: ExternalApplicationReviewRecommendationSummaryV2 { - recommended_count: 2, - optional_count: 1, - blocked_count: 0, - }, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - } -} - -fn application_snapshot_v2() -> ExternalApplicationSnapshotV2 { - ExternalApplicationSnapshotV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: ExecutionDomainId::new("host-a").unwrap(), - workspace_scope_id: Some("workspace:0123456789abcdef".to_string()), - effective_connection_scope: ExternalApplicationTargetScopeV2::WorkspaceOverride, - refresh_generation: 7, - preference_revision: 11, - safe_mode: false, - host_capabilities: ExternalApplicationHostCapabilitiesV2::read_write(), - applications: vec![ExternalApplicationSummaryV2 { - application_id: "opencode".to_string(), - ecosystem_id: "opencode".to_string(), - display_name: "OpenCode".to_string(), - discovery: ExternalApplicationDiscoveryStateV2::Discovered, - connection: ExternalApplicationConnectionStateV2::Connected, - desired_connection: ExternalApplicationDesiredConnectionV2::Connected, - health: ExternalApplicationHealthV2::Healthy, - effective_status: ExternalApplicationEffectiveStatusV2::NeedsAttention, - primary_action: ExternalApplicationPrimaryActionV2::Review, - default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2::Connect, - default_connection_reason: "supported_by_product".to_string(), - enabled_count: 2, - pending_review_count: 3, - blocked_count: 0, - conflict_count: 0, - risk_summary: application_risk_summary(), - notice_key: Some("opencode:review:7".to_string()), - user_decision: ExternalApplicationUserDecisionV2::Connected, - recovery_actions: vec![ExternalApplicationRecoveryActionV2::Review], - }], - review_summary: Some(application_review_summary()), - } -} - -#[test] -fn external_application_snapshot_v2_keeps_review_items_out_of_the_home_snapshot() { - let snapshot = application_snapshot_v2(); - snapshot.validate().unwrap(); - - let encoded = serde_json::to_value(&snapshot).unwrap(); - assert_eq!(encoded["schemaVersion"], EXTERNAL_APPLICATION_SCHEMA_V2); - assert_eq!(encoded["workspaceScopeId"], "workspace:0123456789abcdef"); - assert_eq!(encoded["effectiveConnectionScope"], "workspace_override"); - assert_eq!( - encoded["applications"][0]["effectiveStatus"], - "needs_attention" - ); - assert_eq!(encoded["applications"][0]["primaryAction"], "review"); - assert_eq!(encoded["reviewSummary"]["totalCount"], 3); - assert!(encoded["reviewSummary"].get("items").is_none()); - assert!(encoded["applications"][0].get("reviewSummary").is_none()); - assert!(serde_json::from_value::(serde_json::json!({ - "schemaVersion": 2, - "executionDomainId": "host-a", - "workspaceScopeId": "workspace:0123456789abcdef", - "effectiveConnectionScope": "workspace_override", - "refreshGeneration": 7, - "preferenceRevision": 11, - "safeMode": false, - "hostCapabilities": serde_json::to_value(ExternalApplicationHostCapabilitiesV2::read_write()).unwrap(), - "applications": [], - "reviewSummary": null, - "unexpected": true - })) - .is_err()); -} - -#[test] -fn external_application_v2_unknown_enums_fail_closed() { - let mut encoded = serde_json::to_value(application_snapshot_v2()).unwrap(); - encoded["applications"][0]["effectiveStatus"] = serde_json::json!("future_status"); - - assert!(serde_json::from_value::(encoded).is_err()); -} - -#[test] -fn external_application_status_v2_uses_one_shared_priority_and_primary_action() { - use ExternalApplicationConnectionStateV2::{Connected, Disconnected}; - use ExternalApplicationDiscoveryStateV2::{Discovered, NotDiscovered}; - use ExternalApplicationEffectiveStatusV2::{ - ConfigurationAvailable, Connected as ConnectedStatus, NeedsAttention, NoConfiguration, - TemporarilyUnavailable, - }; - use ExternalApplicationPrimaryActionV2::{Connect, None, Retry, Review, View, ViewReason}; - - let cases = [ - ( - true, - true, - true, - Connected, - Discovered, - (NeedsAttention, Review), - ), - ( - false, - true, - true, - Connected, - Discovered, - (TemporarilyUnavailable, Retry), - ), - ( - false, - true, - false, - Connected, - Discovered, - (TemporarilyUnavailable, ViewReason), - ), - ( - false, - false, - false, - Connected, - Discovered, - (ConnectedStatus, View), - ), - ( - false, - false, - false, - Disconnected, - Discovered, - (ConfigurationAvailable, Connect), - ), - ( - false, - false, - false, - Disconnected, - NotDiscovered, - (NoConfiguration, None), - ), - ]; - - for (needs_attention, temporarily_unavailable, can_retry, connection, discovery, expected) in - cases - { - assert_eq!( - derive_external_application_status_v2( - needs_attention, - temporarily_unavailable, - can_retry, - connection, - discovery, - ), - expected - ); - } -} - -#[test] -fn external_application_v2_host_capabilities_stay_at_current_host_boundaries() { - assert_eq!( - serde_json::to_value(ExternalApplicationHostCapabilitiesV2::read_write()).unwrap(), - serde_json::json!({ - "canReadSnapshot": true, - "canReadReview": true, - "canMutate": true, - "canManageUserDefault": true, - "canManageWorkspaceOverride": true, - "canRefresh": true, - "canSetSafeMode": true - }) - ); - assert_eq!( - serde_json::to_value(ExternalApplicationHostCapabilitiesV2::read_only()).unwrap(), - serde_json::json!({ - "canReadSnapshot": true, - "canReadReview": true, - "canMutate": false, - "canManageUserDefault": false, - "canManageWorkspaceOverride": false, - "canRefresh": true, - "canSetSafeMode": false - }) - ); -} - -#[test] -fn external_application_review_pages_are_bounded_and_carry_only_stable_refs() { - let item = ExternalApplicationReviewItemV2 { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: "opencode.tool:project:review".to_string(), - }, - display_name: "Review tool".to_string(), - display_summary: "Runs the external review tool".to_string(), - risk_level: ExternalApplicationRiskLevelV2::High, - risk_reason_codes: vec!["process_execution".to_string()], - recommended: false, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - }; - let page = ExternalApplicationReviewPageV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: ExecutionDomainId::new("host-a").unwrap(), - workspace_scope_id: Some("workspace:0123456789abcdef".to_string()), - target_scope: ExternalApplicationTargetScopeV2::WorkspaceOverride, - review_id: "review-opencode-7".to_string(), - preference_revision: 11, - expected_generations: vec![ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Tool, - generation: 7, - }], - cursor: None, - next_cursor: Some("page:2".to_string()), - total_count: 129, - items: vec![item.clone(); EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS], - }; - page.validate().unwrap(); - - let mut oversized = page.clone(); - oversized.items.push(item); - assert_eq!( - oversized.validate(), - Err("external application review page exceeds 128 items") - ); - - let encoded = serde_json::to_value(page).unwrap(); - assert!(encoded["items"][0].get("command").is_none()); - assert!(encoded["items"][0].get("prompt").is_none()); - assert!(encoded["items"][0].get("payload").is_none()); - assert_eq!( - encoded["items"][0]["itemRef"]["stableId"], - "opencode.tool:project:review" - ); -} - -#[test] -fn external_application_review_page_requests_enforce_scope_and_page_size() { - let request = ExternalApplicationReviewPageRequestV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: ExecutionDomainId::new("host-a").unwrap(), - workspace_scope_id: Some("workspace:0123456789abcdef".to_string()), - target_scope: ExternalApplicationTargetScopeV2::WorkspaceOverride, - review_id: "review-opencode-7".to_string(), - preference_revision: 11, - expected_generations: vec![ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Tool, - generation: 7, - }], - cursor: None, - page_size: EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, - }; - request.validate().unwrap(); - - let mut oversized = request.clone(); - oversized.page_size += 1; - assert_eq!( - oversized.validate(), - Err("external application review page size must be between 1 and 128") - ); - - let mut leaked_workspace = request; - leaked_workspace.target_scope = ExternalApplicationTargetScopeV2::UserDefault; - assert_eq!( - leaked_workspace.validate(), - Err("user-default scope must not include a workspace scope id") - ); -} - -#[test] -fn external_application_control_v2_uses_a_typed_scope_and_closed_review_action() { - let request = ExternalApplicationControlRequestV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: ExecutionDomainId::new("host-a").unwrap(), - workspace_scope_id: Some("workspace:0123456789abcdef".to_string()), - target_scope: ExternalApplicationTargetScopeV2::WorkspaceOverride, - operation_id: "review-operation-1".to_string(), - expected_preference_revision: 11, - action: ExternalApplicationControlActionV2::SubmitApplicationReview { - review_id: "review-opencode-7".to_string(), - expected_generations: vec![ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Tool, - generation: 7, - }], - selection_baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, - selection_overrides: vec![ExternalApplicationReviewSelectionOverrideV2 { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: "opencode.tool:project:review".to_string(), - }, - selected: true, - }], - }, - }; - request.validate().unwrap(); - - let encoded = serde_json::to_value(&request).unwrap(); - assert_eq!(encoded["action"]["type"], "submit_application_review"); - assert_eq!(encoded["action"]["selectionBaseline"], "recommended"); - assert!(encoded["action"].get("payload").is_none()); - assert_eq!( - serde_json::from_value::(encoded).unwrap(), - request - ); -} - -#[test] -fn external_application_control_results_keep_item_failures_typed() { - let result = ExternalApplicationControlResultV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - operation_id: "review-operation-1".to_string(), - preference_revision: 12, - outcome: ExternalApplicationOperationOutcomeV2::Applied, - item_results: vec![ExternalApplicationReviewItemResultV2 { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: "opencode.tool:project:review".to_string(), - }, - outcome: ExternalApplicationOperationOutcomeV2::Blocked, - reason_code: Some("safe_mode".to_string()), - recovery_actions: vec![ExternalApplicationRecoveryActionV2::ExitSafeMode], - }], - }; - result.validate().unwrap(); - - let encoded = serde_json::to_value(&result).unwrap(); - assert_eq!(encoded["outcome"], "applied"); - assert_eq!(encoded["itemResults"][0]["outcome"], "blocked"); - assert_eq!( - encoded["itemResults"][0]["recoveryActions"][0]["type"], - "exit_safe_mode" - ); - - let mut unknown = encoded; - unknown["itemResults"][0]["outcome"] = serde_json::json!("future_success"); - assert!(serde_json::from_value::(unknown).is_err()); -} - -#[test] -fn v1_control_wire_golden_remains_unchanged_beside_v2() { - let request = ExternalSourceControlRequestV1 { - schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, - operation_id: "legacy-operation".to_string(), - expected_preference_revision: Some(9), - action: ExternalSourceControlActionV1::SetSafeMode { enabled: true }, - }; - - assert_eq!( - serde_json::to_value(request).unwrap(), - serde_json::json!({ - "schemaVersion": 1, - "operationId": "legacy-operation", - "expectedPreferenceRevision": 9, - "action": { "type": "set_safe_mode", "enabled": true } - }) - ); -} diff --git a/src/crates/interfaces/app-server-client/src/lib.rs b/src/crates/interfaces/app-server-client/src/lib.rs index 9c13b7a79..155e327fe 100644 --- a/src/crates/interfaces/app-server-client/src/lib.rs +++ b/src/crates/interfaces/app-server-client/src/lib.rs @@ -215,28 +215,6 @@ impl AppServerClient { self.rpc(|cx| Ok(cx.send_request(request))).await } - pub async fn external_application_snapshot_v2( - &self, - request: ExternalApplicationSnapshotRequestV2, - ) -> agent_client_protocol::Result { - self.rpc(|cx| Ok(cx.send_request(request))).await - } - - pub async fn external_application_review_page_v2( - &self, - request: ExternalApplicationReviewPageRequest, - ) -> agent_client_protocol::Result { - self.rpc(|cx| Ok(cx.send_request(request))).await - } - - pub async fn apply_external_application_action_v2( - &self, - request: ExternalApplicationActionRequest, - ) -> Result { - self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) - .await - } - pub async fn external_source_control( &self, request: ExternalSourceControlRequest, @@ -811,15 +789,3 @@ pub async fn connect( shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), }) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn client_exposes_external_application_v2_methods() { - let _ = AppServerClient::external_application_snapshot_v2; - let _ = AppServerClient::external_application_review_page_v2; - let _ = AppServerClient::apply_external_application_action_v2; - } -} diff --git a/src/crates/interfaces/app-server-protocol/src/lib.rs b/src/crates/interfaces/app-server-protocol/src/lib.rs index 7ede60561..7f9636060 100644 --- a/src/crates/interfaces/app-server-protocol/src/lib.rs +++ b/src/crates/interfaces/app-server-protocol/src/lib.rs @@ -25,3 +25,13 @@ pub const PROTOCOL_VERSION: u32 = 3; /// Oldest protocol version this implementation accepts. pub const MIN_PROTOCOL_VERSION: u32 = 2; + +#[cfg(test)] +mod protocol_version_tests { + use super::PROTOCOL_VERSION; + + #[test] + fn application_protocol_stays_at_version_3() { + assert_eq!(PROTOCOL_VERSION, 3); + } +} diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs b/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs index 079c79abb..2fa52d9be 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs @@ -7,10 +7,8 @@ use std::collections::{BTreeMap, BTreeSet}; use agent_client_protocol::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}; use bitfun_product_domains::external_source_control::{ - ExternalApplicationControlRequestV2, ExternalApplicationControlResultV2, - ExternalApplicationReviewPageRequestV2 as DomainExternalApplicationReviewPageRequestV2, - ExternalApplicationReviewPageV2, ExternalApplicationSnapshotV2, ExternalSourceControlRequestV1, - ExternalSourceControlSnapshotV1, ExternalSourceSurfaceSnapshotV1, + ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, + ExternalSourceSurfaceSnapshotV1, }; use bitfun_product_domains::external_sources::{ ExternalSourceOperationError, ExternalSourcePublicSnapshot, @@ -44,51 +42,6 @@ pub struct ExternalSourceSnapshotResponse { pub preferences: ExternalSourceConflictPreferences, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request( - method = "externalSource/applicationSnapshotV2", - response = ExternalApplicationSnapshotResponseV2 -)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationSnapshotRequestV2 { - pub workspace_path: Option, - pub force_refresh: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[serde(transparent)] -pub struct ExternalApplicationSnapshotResponseV2(pub ExternalApplicationSnapshotV2); - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request( - method = "externalSource/applicationReviewPageV2", - response = ExternalApplicationReviewPageResponseV2 -)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewPageRequest { - pub workspace_path: Option, - pub request: DomainExternalApplicationReviewPageRequestV2, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[serde(transparent)] -pub struct ExternalApplicationReviewPageResponseV2(pub ExternalApplicationReviewPageV2); - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request( - method = "externalSource/applicationActionV2", - response = ExternalApplicationActionResponseV2 -)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationActionRequest { - pub workspace_path: Option, - pub request: ExternalApplicationControlRequestV2, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[serde(transparent)] -pub struct ExternalApplicationActionResponseV2(pub ExternalApplicationControlResultV2); - #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] #[notification(method = "externalSource/event")] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -285,113 +238,4 @@ mod tests { assert!(!debug.contains("C:/secret/project")); assert!(!debug.contains("--token secret")); } - - #[test] - fn application_v2_wire_requests_keep_workspace_binding_outside_domain_payloads() { - let snapshot: ExternalApplicationSnapshotRequestV2 = - serde_json::from_value(serde_json::json!({ - "workspacePath": null, - "forceRefresh": true - })) - .unwrap(); - assert_eq!(snapshot.workspace_path, None); - assert!(snapshot.force_refresh); - - let page: ExternalApplicationReviewPageRequest = - serde_json::from_value(serde_json::json!({ - "workspacePath": "C:/work/project", - "request": { - "schemaVersion": 2, - "executionDomainId": "host-a", - "workspaceScopeId": "workspace-a", - "targetScope": "workspace_override", - "reviewId": "review-a", - "preferenceRevision": 4, - "expectedGenerations": [], - "pageSize": 64 - } - })) - .unwrap(); - assert_eq!(page.workspace_path.as_deref(), Some("C:/work/project")); - assert_eq!(page.request.page_size, 64); - - let action: ExternalApplicationActionRequest = serde_json::from_value(serde_json::json!({ - "workspacePath": "C:/work/project", - "request": { - "schemaVersion": 2, - "executionDomainId": "host-a", - "workspaceScopeId": "workspace-a", - "targetScope": "workspace_override", - "operationId": "operation-a", - "expectedPreferenceRevision": 4, - "action": { - "type": "connect_application", - "applicationId": "opencode" - } - } - })) - .unwrap(); - assert_eq!(action.workspace_path.as_deref(), Some("C:/work/project")); - assert_eq!(action.request.operation_id, "operation-a"); - } - - #[test] - fn application_v2_snapshot_response_serializes_as_the_domain_object() { - let domain_json = serde_json::json!({ - "schemaVersion": 2, - "executionDomainId": "host-a", - "effectiveConnectionScope": "user_default", - "refreshGeneration": 7, - "preferenceRevision": 4, - "safeMode": false, - "hostCapabilities": { - "canReadSnapshot": true, - "canReadReview": true, - "canMutate": true, - "canManageUserDefault": true, - "canManageWorkspaceOverride": true, - "canRefresh": true, - "canSetSafeMode": true - }, - "applications": [] - }); - let domain: ExternalApplicationSnapshotV2 = - serde_json::from_value(domain_json.clone()).unwrap(); - - assert_eq!( - serde_json::to_value(ExternalApplicationSnapshotResponseV2(domain)).unwrap(), - domain_json - ); - - let page_json = serde_json::json!({ - "schemaVersion": 2, - "executionDomainId": "host-a", - "targetScope": "user_default", - "reviewId": "review-a", - "preferenceRevision": 4, - "expectedGenerations": [], - "totalCount": 0, - "items": [] - }); - let page: ExternalApplicationReviewPageV2 = - serde_json::from_value(page_json.clone()).unwrap(); - assert_eq!( - serde_json::to_value(ExternalApplicationReviewPageResponseV2(page)).unwrap(), - page_json - ); - - let action_json = serde_json::json!({ - "schemaVersion": 2, - "operationId": "operation-a", - "preferenceRevision": 5, - "outcome": "applied", - "itemResults": [] - }); - let action: ExternalApplicationControlResultV2 = - serde_json::from_value(action_json.clone()).unwrap(); - assert_eq!( - serde_json::to_value(ExternalApplicationActionResponseV2(action)).unwrap(), - action_json - ); - } } diff --git a/src/crates/interfaces/app-server/src/management.rs b/src/crates/interfaces/app-server/src/management.rs index 2c6e2d04a..712744748 100644 --- a/src/crates/interfaces/app-server/src/management.rs +++ b/src/crates/interfaces/app-server/src/management.rs @@ -398,34 +398,4 @@ mod tests { ] ); } - - #[test] - fn external_source_capability_does_not_advertise_unwired_shared_v2_methods() { - let external_sources = AppManagementCapabilities::available() - .descriptors() - .into_iter() - .find(|descriptor| descriptor.id == EXTERNAL_SOURCES_CAPABILITY) - .expect("external source capability"); - - for method in [ - "externalSource/snapshot", - "externalSource/control", - "externalSource/review", - ] { - assert!( - external_sources.methods.iter().any(|item| item == method), - "missing {method}" - ); - } - for method in [ - "externalSource/applicationSnapshotV2", - "externalSource/applicationReviewPageV2", - "externalSource/applicationActionV2", - ] { - assert!( - !external_sources.methods.iter().any(|item| item == method), - "shared capability must not advertise unwired method {method}" - ); - } - } } diff --git a/src/crates/interfaces/app-server/src/management/service.rs b/src/crates/interfaces/app-server/src/management/service.rs index 12452dc49..28c7993c8 100644 --- a/src/crates/interfaces/app-server/src/management/service.rs +++ b/src/crates/interfaces/app-server/src/management/service.rs @@ -1025,55 +1025,6 @@ impl AppManagementService { external_source_snapshot_response(workspace, request.force_refresh).await } - pub async fn external_application_snapshot_v2( - &self, - request: ExternalApplicationSnapshotRequestV2, - ) -> AppManagementResult { - bitfun_core::external_sources::get_external_application_snapshot_v2( - request.workspace_path.as_deref().map(Path::new), - request.force_refresh, - bitfun_product_domains::external_source_control::ExternalApplicationHostCapabilitiesV2::read_write(), - ) - .await - .map(ExternalApplicationSnapshotResponseV2) - .map_err(external_source_string_error) - } - - pub async fn external_application_review_page_v2( - &self, - request: ExternalApplicationReviewPageRequest, - ) -> AppManagementResult { - request - .request - .validate() - .map_err(AppManagementError::invalid_request)?; - bitfun_core::external_sources::get_external_application_review_page_v2( - request.workspace_path.as_deref().map(Path::new), - request.request, - ) - .await - .map(ExternalApplicationReviewPageResponseV2) - .map_err(external_source_string_error) - } - - pub async fn apply_external_application_action_v2( - &self, - request: ExternalApplicationActionRequest, - ) -> AppManagementResult { - request - .request - .validate() - .map_err(AppManagementError::invalid_request)?; - let operation_id = request.request.operation_id.clone(); - bitfun_core::external_sources::apply_external_application_action_v2( - request.workspace_path.as_deref().map(Path::new), - request.request, - ) - .await - .map(ExternalApplicationActionResponseV2) - .map_err(|error| external_source_string_error_with_id(error, &operation_id)) - } - pub async fn external_source_control( &self, request: ExternalSourceControlRequest, diff --git a/src/crates/interfaces/app-server/src/server/handlers/external_source.rs b/src/crates/interfaces/app-server/src/server/handlers/external_source.rs index e749cea24..70437c6b8 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/external_source.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/external_source.rs @@ -13,33 +13,6 @@ pub(in crate::server) fn builder( AppServer .builder() .name("external source handlers") - .on_receive_request( - management_handler!( - management, - EXTERNAL_SOURCES_CAPABILITY, - ExternalApplicationSnapshotRequestV2, - external_application_snapshot_v2 - ), - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - management_handler!( - management, - EXTERNAL_SOURCES_CAPABILITY, - ExternalApplicationReviewPageRequest, - external_application_review_page_v2 - ), - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - management_handler!( - management, - EXTERNAL_SOURCES_CAPABILITY, - ExternalApplicationActionRequest, - apply_external_application_action_v2 - ), - agent_client_protocol::on_receive_request!(), - ) .on_receive_request( management_handler!( management, diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts index ba1e9f0af..c80b1ea37 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts @@ -43,48 +43,6 @@ function surface(catalog: Record) { }; } -function applicationSurfaceV2(overrides: Record = {}) { - return { - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - effectiveConnectionScope: 'workspace_override', - refreshGeneration: 7, - preferenceRevision: 11, - safeMode: false, - hostCapabilities: { - canReadSnapshot: true, - canReadReview: true, - canMutate: true, - canManageUserDefault: true, - canManageWorkspaceOverride: true, - canRefresh: true, - canSetSafeMode: true, - }, - applications: [{ - applicationId: 'opencode', - ecosystemId: 'opencode', - displayName: 'OpenCode', - discovery: 'discovered', - connection: 'connected', - desiredConnection: 'connected', - health: 'healthy', - effectiveStatus: 'connected', - primaryAction: 'view', - defaultConnectionPolicy: 'connect', - defaultConnectionReason: 'supported_by_product', - enabledCount: 2, - pendingReviewCount: 0, - blockedCount: 0, - conflictCount: 0, - riskSummary: { reasonCodes: [] }, - userDecision: 'connected', - recoveryActions: [], - }], - ...overrides, - }; -} - vi.mock('../adapters', async importOriginal => ({ ...await importOriginal(), getTransportAdapter: () => adapterMocks, @@ -108,312 +66,6 @@ describe('ExternalSourcesAPI', () => { adapterMocks.isConnected.mockReturnValue(true); }); - it('provides an application-level V2 negotiation read', () => { - expect('getApplicationSurface' in externalSourcesAPI).toBe(true); - }); - - it('prefers a strict Host-authoritative V2 application snapshot', async () => { - invokeMock.mockResolvedValueOnce(applicationSurfaceV2()); - - await expect(externalSourcesAPI.getApplicationSurface(' D:/workspace/project ', true)) - .resolves.toMatchObject({ - protocol: 'v2', - snapshot: { - schemaVersion: 2, - executionDomainId: 'host-a', - applications: [{ - effectiveStatus: 'connected', - primaryAction: 'view', - }], - }, - }); - expect(invokeMock).toHaveBeenCalledWith('get_external_application_snapshot_v2', { - request: { workspacePath: 'D:/workspace/project', forceRefresh: true }, - }); - }); - - it('fails closed for an unknown V2 application status without falling back to V1', async () => { - invokeMock.mockResolvedValueOnce(applicationSurfaceV2({ - applications: [{ - ...applicationSurfaceV2().applications[0], - effectiveStatus: 'future_status', - }], - })); - - await expect(externalSourcesAPI.getApplicationSurface()).rejects.toMatchObject({ - code: 'invalid_response', - }); - expect(invokeMock).toHaveBeenCalledTimes(1); - }); - - it('normalizes risk reason codes omitted by the Rust empty-vector wire format', async () => { - invokeMock.mockResolvedValueOnce(applicationSurfaceV2({ - applications: [{ - ...applicationSurfaceV2().applications[0], - riskSummary: {}, - }], - reviewSummary: { - reviewId: 'review-a', - totalCount: 0, - categoryCounts: [], - maxSelectionCount: 0, - riskSummary: {}, - recommendationSummary: { recommendedCount: 0, optionalCount: 0, blockedCount: 0 }, - safetyCeiling: 'automatic', - }, - })); - - await expect(externalSourcesAPI.getApplicationSurface()).resolves.toMatchObject({ - protocol: 'v2', - snapshot: { - applications: [{ riskSummary: { reasonCodes: [] } }], - reviewSummary: { riskSummary: { reasonCodes: [] } }, - }, - }); - }); - - it('accepts a workspace context whose effective decision is inherited from the user default', async () => { - invokeMock.mockResolvedValueOnce(applicationSurfaceV2({ - effectiveConnectionScope: 'user_default', - })); - - await expect(externalSourcesAPI.getApplicationSurface()).resolves.toMatchObject({ - protocol: 'v2', - snapshot: { - workspaceScopeId: 'workspace:0123456789abcdef', - effectiveConnectionScope: 'user_default', - }, - }); - }); - - it('falls back to the unchanged V1 surface only when the V2 method is unavailable', async () => { - invokeMock - .mockRejectedValueOnce( - "command 'get_external_application_snapshot_v2' is not supported on CLI peer host", - ) - .mockResolvedValueOnce(surface({ - generation: 3, - discoveryPending: false, - preferenceRevision: 2, - sources: [], - commands: [], - integrationPolicy: { - schemaMajor: 1, - status: 'compatible', - userDefaults: { enabled: false, ecosystems: {} }, - globalEffective: { enabled: false, ecosystems: {} }, - effective: { enabled: false, ecosystems: {} }, - registeredEcosystems: [], - }, - })); - - await expect(externalSourcesAPI.getApplicationSurface('D:/workspace/project')) - .resolves.toMatchObject({ - protocol: 'v1', - snapshot: { generation: 3 }, - }); - expect(invokeMock).toHaveBeenNthCalledWith(2, 'get_external_source_control_snapshot', { - request: { workspacePath: 'D:/workspace/project', forceRefresh: false }, - }); - }); - - it('reads a strict bounded V2 review page with the Host scope kept outside the domain request', async () => { - const request = { - schemaVersion: 2 as const, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override' as const, - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool' as const, generation: 7 }], - pageSize: 64, - }; - invokeMock.mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - nextCursor: 'page-2', - totalCount: 2, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-a' }, - displayName: 'Tool A', - displaySummary: 'Read repository files', - riskLevel: 'moderate', - riskReasonCodes: ['reads_workspace'], - recommended: true, - safetyCeiling: 'review_required', - }], - }); - - await expect(externalSourcesAPI.getApplicationReviewPage( - ' D:/workspace/project ', - request, - )).resolves.toMatchObject({ - nextCursor: 'page-2', - items: [{ riskLevel: 'moderate', recommended: true }], - }); - expect(invokeMock).toHaveBeenCalledWith('get_external_application_review_page_v2', { - request: { workspacePath: 'D:/workspace/project', request }, - }); - }); - - it('accepts an authoritative review rebind only when opening the first page', async () => { - invokeMock.mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-current', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 8 }], - totalCount: 1, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-current' }, - displayName: 'Current Tool', - displaySummary: 'Current item', - riskLevel: 'low', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }); - - await expect(externalSourcesAPI.getApplicationReviewPage('D:/workspace/project', { - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-stale', - preferenceRevision: 11, - expectedGenerations: [], - pageSize: 64, - })).resolves.toMatchObject({ - reviewId: 'review-current', - expectedGenerations: [{ owner: 'tool', generation: 8 }], - }); - }); - - it('fails closed when a V2 review page contains an unknown risk level', async () => { - invokeMock.mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - targetScope: 'user_default', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [], - totalCount: 1, - items: [{ - itemRef: { kind: 'command', stableId: 'command-a' }, - displayName: 'Command A', - displaySummary: 'Run command A', - riskLevel: 'medium', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }); - - await expect(externalSourcesAPI.getApplicationReviewPage(undefined, { - schemaVersion: 2, - executionDomainId: 'host-a', - targetScope: 'user_default', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [], - pageSize: 64, - })).rejects.toMatchObject({ code: 'invalid_response' }); - }); - - it('rejects a validly shaped review page from a different execution domain', async () => { - invokeMock.mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-b', - targetScope: 'user_default', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [], - totalCount: 0, - items: [], - }); - - await expect(externalSourcesAPI.getApplicationReviewPage(undefined, { - schemaVersion: 2, - executionDomainId: 'host-a', - targetScope: 'user_default', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [], - pageSize: 64, - })).rejects.toMatchObject({ code: 'invalid_response' }); - }); - - it('applies a V2 action and preserves typed partial or stale item feedback', async () => { - const request = { - schemaVersion: 2 as const, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override' as const, - operationId: 'operation-a', - expectedPreferenceRevision: 11, - action: { - type: 'submit_application_review' as const, - reviewId: 'review-a', - expectedGenerations: [{ owner: 'tool' as const, generation: 7 }], - selectionBaseline: 'recommended' as const, - selectionOverrides: [{ - itemRef: { kind: 'tool' as const, stableId: 'tool-a' }, - selected: false, - }], - }, - }; - invokeMock.mockResolvedValueOnce({ - schemaVersion: 2, - operationId: 'operation-a', - preferenceRevision: 12, - outcome: 'stale', - itemResults: [{ - itemRef: { kind: 'tool', stableId: 'tool-a' }, - outcome: 'stale', - reasonCode: 'owner_generation_changed', - recoveryActions: [{ type: 'refresh' }], - }], - }); - - await expect(externalSourcesAPI.applyApplicationAction( - 'D:/workspace/project', - request, - )).resolves.toMatchObject({ - outcome: 'stale', - itemResults: [{ recoveryActions: [{ type: 'refresh' }] }], - }); - expect(invokeMock).toHaveBeenCalledWith('apply_external_application_action_v2', { - request: { workspacePath: 'D:/workspace/project', request }, - }); - }); - - it('rejects a V2 action result for a different idempotency operation', async () => { - invokeMock.mockResolvedValueOnce({ - schemaVersion: 2, - operationId: 'operation-b', - preferenceRevision: 12, - outcome: 'applied', - itemResults: [], - }); - - await expect(externalSourcesAPI.applyApplicationAction(undefined, { - schemaVersion: 2, - executionDomainId: 'host-a', - targetScope: 'user_default', - operationId: 'operation-a', - expectedPreferenceRevision: 11, - action: { type: 'refresh' }, - })).rejects.toMatchObject({ code: 'invalid_response' }); - }); - it('reads and acknowledges backend-owned ecosystem awareness', async () => { invokeMock .mockResolvedValueOnce({ unacknowledgedEcosystemIds: ['opencode', 'codex'] }) diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts index 4641a313e..05e0dd321 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -196,225 +196,6 @@ export interface ExternalSourceCatalogSnapshot { control?: ExternalSourceControlSnapshot; } -export type ExternalApplicationTargetScopeV2 = 'user_default' | 'workspace_override'; -export type ExternalApplicationDesiredConnectionV2 = - | 'unspecified' - | 'connected' - | 'disconnected' - | 'deferred' - | 'needs_review'; -export type ExternalApplicationUserDecisionV2 = - | 'none' - | 'connected' - | 'disconnected' - | 'deferred' - | 'needs_review'; -export type ExternalApplicationDiscoveryStateV2 = 'not_discovered' | 'discovered'; -export type ExternalApplicationConnectionStateV2 = 'disconnected' | 'connected'; -export type ExternalApplicationHealthV2 = 'healthy' | 'degraded' | 'unavailable'; -export type ExternalApplicationEffectiveStatusV2 = - | 'connected' - | 'configuration_available' - | 'no_configuration' - | 'needs_attention' - | 'temporarily_unavailable'; -export type ExternalApplicationPrimaryActionV2 = - | 'none' - | 'view' - | 'connect' - | 'review' - | 'retry' - | 'view_reason'; -export type ExternalApplicationDefaultConnectionPolicyV2 = - | 'connect' - | 'discover_only' - | 'unsupported'; -export type ExternalApplicationRiskLevelV2 = 'low' | 'moderate' | 'high'; -export type ExternalApplicationSafetyCeilingV2 = 'blocked' | 'review_required' | 'automatic'; -export type ExternalApplicationRecoveryActionV2 = { - type: - | 'refresh' - | 'retry' - | 'reconnect_host' - | 'review' - | 'upgrade_host' - | 'view_reason' - | 'exit_safe_mode' - | 'resolve_conflict' - | 'install_runtime'; -}; - -export interface ExternalApplicationHostCapabilitiesV2 { - canReadSnapshot: boolean; - canReadReview: boolean; - canMutate: boolean; - canManageUserDefault: boolean; - canManageWorkspaceOverride: boolean; - canRefresh: boolean; - canSetSafeMode: boolean; -} - -export interface ExternalApplicationRiskSummaryV2 { - highestLevel?: ExternalApplicationRiskLevelV2; - reasonCodes: string[]; -} - -export type ExternalApplicationReviewItemKindV2 = - | 'command' - | 'tool' - | 'subagent' - | 'mcp' - | 'conflict'; - -export interface ExternalApplicationReviewSummaryV2 { - reviewId: string; - totalCount: number; - categoryCounts: Array<{ kind: ExternalApplicationReviewItemKindV2; count: number }>; - maxSelectionCount: number; - riskSummary: ExternalApplicationRiskSummaryV2; - recommendationSummary: { - recommendedCount: number; - optionalCount: number; - blockedCount: number; - }; - safetyCeiling: ExternalApplicationSafetyCeilingV2; -} - -export interface ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2; - stableId: string; -} - -export interface ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2; - generation: number; -} - -export interface ExternalApplicationReviewItemV2 { - itemRef: ExternalApplicationReviewItemRefV2; - displayName: string; - displaySummary: string; - riskLevel: ExternalApplicationRiskLevelV2; - riskReasonCodes: string[]; - recommended: boolean; - safetyCeiling: ExternalApplicationSafetyCeilingV2; -} - -export interface ExternalApplicationReviewPageRequestV2 { - schemaVersion: 2; - executionDomainId: string; - workspaceScopeId?: string; - targetScope: ExternalApplicationTargetScopeV2; - reviewId: string; - preferenceRevision: number; - expectedGenerations: ExternalApplicationOwnerGenerationV2[]; - cursor?: string; - pageSize: number; -} - -export interface ExternalApplicationReviewPageV2 { - schemaVersion: 2; - executionDomainId: string; - workspaceScopeId?: string; - targetScope: ExternalApplicationTargetScopeV2; - reviewId: string; - preferenceRevision: number; - expectedGenerations: ExternalApplicationOwnerGenerationV2[]; - cursor?: string; - nextCursor?: string; - totalCount: number; - items: ExternalApplicationReviewItemV2[]; -} - -export type ExternalApplicationReviewSelectionBaselineV2 = 'recommended' | 'none'; - -export interface ExternalApplicationReviewSelectionOverrideV2 { - itemRef: ExternalApplicationReviewItemRefV2; - selected: boolean; -} - -export type ExternalApplicationControlActionV2 = - | { type: 'connect_application'; applicationId: string } - | { type: 'disconnect_application'; applicationId: string } - | { type: 'set_application_deferred'; applicationId: string } - | { - type: 'submit_application_review'; - reviewId: string; - expectedGenerations: ExternalApplicationOwnerGenerationV2[]; - selectionBaseline: ExternalApplicationReviewSelectionBaselineV2; - selectionOverrides: ExternalApplicationReviewSelectionOverrideV2[]; - } - | { type: 'refresh' } - | { type: 'set_source_enabled'; sourceKey: string; enabled: boolean } - | { type: 'set_safe_mode'; enabled: boolean }; - -export interface ExternalApplicationControlRequestV2 { - schemaVersion: 2; - executionDomainId: string; - workspaceScopeId?: string; - targetScope: ExternalApplicationTargetScopeV2; - operationId: string; - expectedPreferenceRevision: number; - action: ExternalApplicationControlActionV2; -} - -export type ExternalApplicationOperationOutcomeV2 = - | 'applied' - | 'rejected' - | 'blocked' - | 'stale' - | 'failed'; - -export interface ExternalApplicationReviewItemResultV2 { - itemRef: ExternalApplicationReviewItemRefV2; - outcome: ExternalApplicationOperationOutcomeV2; - reasonCode?: string; - recoveryActions: ExternalApplicationRecoveryActionV2[]; -} - -export interface ExternalApplicationControlResultV2 { - schemaVersion: 2; - operationId: string; - preferenceRevision: number; - outcome: ExternalApplicationOperationOutcomeV2; - itemResults: ExternalApplicationReviewItemResultV2[]; -} - -export interface ExternalApplicationSummaryV2 { - applicationId: string; - ecosystemId: string; - displayName: string; - discovery: ExternalApplicationDiscoveryStateV2; - connection: ExternalApplicationConnectionStateV2; - desiredConnection: ExternalApplicationDesiredConnectionV2; - health: ExternalApplicationHealthV2; - effectiveStatus: ExternalApplicationEffectiveStatusV2; - primaryAction: ExternalApplicationPrimaryActionV2; - defaultConnectionPolicy: ExternalApplicationDefaultConnectionPolicyV2; - defaultConnectionReason: string; - enabledCount: number; - pendingReviewCount: number; - blockedCount: number; - conflictCount: number; - riskSummary: ExternalApplicationRiskSummaryV2; - noticeKey?: string; - userDecision: ExternalApplicationUserDecisionV2; - recoveryActions: ExternalApplicationRecoveryActionV2[]; -} - -export interface ExternalApplicationSnapshotV2 { - schemaVersion: 2; - executionDomainId: string; - workspaceScopeId?: string; - effectiveConnectionScope: ExternalApplicationTargetScopeV2; - refreshGeneration: number; - preferenceRevision: number; - safeMode: boolean; - hostCapabilities: ExternalApplicationHostCapabilitiesV2; - applications: ExternalApplicationSummaryV2[]; - reviewSummary?: ExternalApplicationReviewSummaryV2; -} - export interface PromptCommandShellReviewPlan { schemaVersion: number; planFingerprint: string; @@ -981,399 +762,6 @@ function isNonNegativeInteger(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; } -const APPLICATION_TARGET_SCOPES = new Set([ - 'user_default', 'workspace_override', -]); -const APPLICATION_DESIRED_CONNECTIONS = new Set([ - 'unspecified', 'connected', 'disconnected', 'deferred', 'needs_review', -]); -const APPLICATION_USER_DECISIONS = new Set([ - 'none', 'connected', 'disconnected', 'deferred', 'needs_review', -]); -const APPLICATION_DISCOVERY_STATES = new Set([ - 'not_discovered', 'discovered', -]); -const APPLICATION_CONNECTION_STATES = new Set([ - 'disconnected', 'connected', -]); -const APPLICATION_HEALTH_STATES = new Set([ - 'healthy', 'degraded', 'unavailable', -]); -const APPLICATION_EFFECTIVE_STATUSES = new Set([ - 'connected', - 'configuration_available', - 'no_configuration', - 'needs_attention', - 'temporarily_unavailable', -]); -const APPLICATION_PRIMARY_ACTIONS = new Set([ - 'none', 'view', 'connect', 'review', 'retry', 'view_reason', -]); -const APPLICATION_DEFAULT_POLICIES = new Set([ - 'connect', 'discover_only', 'unsupported', -]); -const APPLICATION_RISK_LEVELS = new Set([ - 'low', 'moderate', 'high', -]); -const APPLICATION_SAFETY_CEILINGS = new Set([ - 'blocked', 'review_required', 'automatic', -]); -const APPLICATION_REVIEW_KINDS = new Set([ - 'command', 'tool', 'subagent', 'mcp', 'conflict', -]); -const APPLICATION_RECOVERY_ACTIONS = new Set([ - 'refresh', - 'retry', - 'reconnect_host', - 'review', - 'upgrade_host', - 'view_reason', - 'exit_safe_mode', - 'resolve_conflict', - 'install_runtime', -]); -const APPLICATION_OPERATION_OUTCOMES = new Set([ - 'applied', 'rejected', 'blocked', 'stale', 'failed', -]); - -function isExactRecord( - value: unknown, - allowedKeys: readonly string[], - requiredKeys: readonly string[] = allowedKeys, -): value is Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const record = value as Record; - return Object.keys(record).every((key) => allowedKeys.includes(key)) - && requiredKeys.every((key) => Object.prototype.hasOwnProperty.call(record, key)); -} - -function isApplicationRiskSummary(value: unknown): value is ExternalApplicationRiskSummaryV2 { - if (!isExactRecord(value, ['highestLevel', 'reasonCodes'], [])) return false; - return (value.highestLevel === undefined - || value.highestLevel === null - || isOneOf(value.highestLevel, APPLICATION_RISK_LEVELS)) - && (value.reasonCodes === undefined - || (Array.isArray(value.reasonCodes) - && value.reasonCodes.every((reason) => typeof reason === 'string'))); -} - -function isApplicationRecoveryAction( - value: unknown, -): value is ExternalApplicationRecoveryActionV2 { - return isExactRecord(value, ['type']) && isOneOf(value.type, APPLICATION_RECOVERY_ACTIONS); -} - -function isApplicationReviewItemRef( - value: unknown, -): value is ExternalApplicationReviewItemRefV2 { - return isExactRecord(value, ['kind', 'stableId']) - && isOneOf(value.kind, APPLICATION_REVIEW_KINDS) - && typeof value.stableId === 'string'; -} - -function isApplicationOwnerGeneration( - value: unknown, -): value is ExternalApplicationOwnerGenerationV2 { - return isExactRecord(value, ['owner', 'generation']) - && isOneOf(value.owner, APPLICATION_REVIEW_KINDS) - && isNonNegativeInteger(value.generation); -} - -function isApplicationReviewItem(value: unknown): value is ExternalApplicationReviewItemV2 { - return isExactRecord(value, [ - 'itemRef', - 'displayName', - 'displaySummary', - 'riskLevel', - 'riskReasonCodes', - 'recommended', - 'safetyCeiling', - ]) - && isApplicationReviewItemRef(value.itemRef) - && typeof value.displayName === 'string' - && typeof value.displaySummary === 'string' - && isOneOf(value.riskLevel, APPLICATION_RISK_LEVELS) - && Array.isArray(value.riskReasonCodes) - && value.riskReasonCodes.every((reason) => typeof reason === 'string') - && typeof value.recommended === 'boolean' - && isOneOf(value.safetyCeiling, APPLICATION_SAFETY_CEILINGS); -} - -function isApplicationHostCapabilities( - value: unknown, -): value is ExternalApplicationHostCapabilitiesV2 { - const keys = [ - 'canReadSnapshot', - 'canReadReview', - 'canMutate', - 'canManageUserDefault', - 'canManageWorkspaceOverride', - 'canRefresh', - 'canSetSafeMode', - ] as const; - return isExactRecord(value, keys) && keys.every((key) => typeof value[key] === 'boolean'); -} - -function isApplicationSummary(value: unknown): value is ExternalApplicationSummaryV2 { - const keys = [ - 'applicationId', - 'ecosystemId', - 'displayName', - 'discovery', - 'connection', - 'desiredConnection', - 'health', - 'effectiveStatus', - 'primaryAction', - 'defaultConnectionPolicy', - 'defaultConnectionReason', - 'enabledCount', - 'pendingReviewCount', - 'blockedCount', - 'conflictCount', - 'riskSummary', - 'noticeKey', - 'userDecision', - 'recoveryActions', - ] as const; - if (!isExactRecord(value, keys, keys.filter((key) => key !== 'noticeKey'))) return false; - return typeof value.applicationId === 'string' - && typeof value.ecosystemId === 'string' - && typeof value.displayName === 'string' - && isOneOf(value.discovery, APPLICATION_DISCOVERY_STATES) - && isOneOf(value.connection, APPLICATION_CONNECTION_STATES) - && isOneOf(value.desiredConnection, APPLICATION_DESIRED_CONNECTIONS) - && isOneOf(value.health, APPLICATION_HEALTH_STATES) - && isOneOf(value.effectiveStatus, APPLICATION_EFFECTIVE_STATUSES) - && isOneOf(value.primaryAction, APPLICATION_PRIMARY_ACTIONS) - && isOneOf(value.defaultConnectionPolicy, APPLICATION_DEFAULT_POLICIES) - && typeof value.defaultConnectionReason === 'string' - && isNonNegativeInteger(value.enabledCount) - && isNonNegativeInteger(value.pendingReviewCount) - && isNonNegativeInteger(value.blockedCount) - && isNonNegativeInteger(value.conflictCount) - && isApplicationRiskSummary(value.riskSummary) - && (value.noticeKey === undefined || value.noticeKey === null || typeof value.noticeKey === 'string') - && isOneOf(value.userDecision, APPLICATION_USER_DECISIONS) - && Array.isArray(value.recoveryActions) - && value.recoveryActions.every(isApplicationRecoveryAction); -} - -function isApplicationReviewSummary( - value: unknown, -): value is ExternalApplicationReviewSummaryV2 { - const keys = [ - 'reviewId', - 'totalCount', - 'categoryCounts', - 'maxSelectionCount', - 'riskSummary', - 'recommendationSummary', - 'safetyCeiling', - ] as const; - if (!isExactRecord(value, keys) - || typeof value.reviewId !== 'string' - || !isNonNegativeInteger(value.totalCount) - || !isNonNegativeInteger(value.maxSelectionCount) - || value.maxSelectionCount > value.totalCount - || !isApplicationRiskSummary(value.riskSummary) - || !isOneOf(value.safetyCeiling, APPLICATION_SAFETY_CEILINGS) - || !Array.isArray(value.categoryCounts) - || !value.categoryCounts.every((entry) => ( - isExactRecord(entry, ['kind', 'count']) - && isOneOf(entry.kind, APPLICATION_REVIEW_KINDS) - && isNonNegativeInteger(entry.count) - )) - || !isExactRecord( - value.recommendationSummary, - ['recommendedCount', 'optionalCount', 'blockedCount'], - )) return false; - return isNonNegativeInteger(value.recommendationSummary.recommendedCount) - && isNonNegativeInteger(value.recommendationSummary.optionalCount) - && isNonNegativeInteger(value.recommendationSummary.blockedCount); -} - -export function normalizeExternalApplicationSnapshotV2( - value: unknown, -): ExternalApplicationSnapshotV2 { - const keys = [ - 'schemaVersion', - 'executionDomainId', - 'workspaceScopeId', - 'effectiveConnectionScope', - 'refreshGeneration', - 'preferenceRevision', - 'safeMode', - 'hostCapabilities', - 'applications', - 'reviewSummary', - ] as const; - const required = keys.filter( - (key) => key !== 'workspaceScopeId' && key !== 'reviewSummary', - ); - if (!isExactRecord(value, keys, required) - || value.schemaVersion !== 2 - || typeof value.executionDomainId !== 'string' - || (value.workspaceScopeId !== undefined - && value.workspaceScopeId !== null - && typeof value.workspaceScopeId !== 'string') - || !isOneOf(value.effectiveConnectionScope, APPLICATION_TARGET_SCOPES) - || !isNonNegativeInteger(value.refreshGeneration) - || !isNonNegativeInteger(value.preferenceRevision) - || typeof value.safeMode !== 'boolean' - || !isApplicationHostCapabilities(value.hostCapabilities) - || !Array.isArray(value.applications) - || !value.applications.every(isApplicationSummary) - || (value.reviewSummary !== undefined - && value.reviewSummary !== null - && !isApplicationReviewSummary(value.reviewSummary))) { - throw new ExternalSourceApiError( - 'invalid_response', - 'External application V2 snapshot schema was invalid', - false, - ); - } - return { - ...value, - workspaceScopeId: typeof value.workspaceScopeId === 'string' - ? value.workspaceScopeId - : undefined, - applications: value.applications.map((application) => ({ - ...application, - noticeKey: typeof application.noticeKey === 'string' ? application.noticeKey : undefined, - riskSummary: { - ...(application.riskSummary.highestLevel - ? { highestLevel: application.riskSummary.highestLevel } - : {}), - reasonCodes: [...(application.riskSummary.reasonCodes ?? [])], - }, - recoveryActions: [...application.recoveryActions], - })), - reviewSummary: value.reviewSummary && isApplicationReviewSummary(value.reviewSummary) - ? { - ...value.reviewSummary, - categoryCounts: [...value.reviewSummary.categoryCounts], - riskSummary: { - ...(value.reviewSummary.riskSummary.highestLevel - ? { highestLevel: value.reviewSummary.riskSummary.highestLevel } - : {}), - reasonCodes: [...(value.reviewSummary.riskSummary.reasonCodes ?? [])], - }, - recommendationSummary: { ...value.reviewSummary.recommendationSummary }, - } - : undefined, - } as ExternalApplicationSnapshotV2; -} - -export function normalizeExternalApplicationReviewPageV2( - value: unknown, -): ExternalApplicationReviewPageV2 { - const keys = [ - 'schemaVersion', - 'executionDomainId', - 'workspaceScopeId', - 'targetScope', - 'reviewId', - 'preferenceRevision', - 'expectedGenerations', - 'cursor', - 'nextCursor', - 'totalCount', - 'items', - ] as const; - const required = keys.filter( - (key) => key !== 'workspaceScopeId' && key !== 'cursor' && key !== 'nextCursor', - ); - if (!isExactRecord(value, keys, required) - || value.schemaVersion !== 2 - || typeof value.executionDomainId !== 'string' - || (value.workspaceScopeId !== undefined - && value.workspaceScopeId !== null - && typeof value.workspaceScopeId !== 'string') - || !isOneOf(value.targetScope, APPLICATION_TARGET_SCOPES) - || (value.targetScope === 'workspace_override' && typeof value.workspaceScopeId !== 'string') - || (value.targetScope === 'user_default' && value.workspaceScopeId != null) - || typeof value.reviewId !== 'string' - || !isNonNegativeInteger(value.preferenceRevision) - || !Array.isArray(value.expectedGenerations) - || !value.expectedGenerations.every(isApplicationOwnerGeneration) - || (value.cursor !== undefined && value.cursor !== null && typeof value.cursor !== 'string') - || (value.nextCursor !== undefined - && value.nextCursor !== null - && typeof value.nextCursor !== 'string') - || !isNonNegativeInteger(value.totalCount) - || !Array.isArray(value.items) - || value.items.length > 128 - || value.items.length > value.totalCount - || !value.items.every(isApplicationReviewItem)) { - throw new ExternalSourceApiError( - 'invalid_response', - 'External application V2 review page schema was invalid', - false, - ); - } - return { - ...value, - workspaceScopeId: typeof value.workspaceScopeId === 'string' - ? value.workspaceScopeId - : undefined, - cursor: typeof value.cursor === 'string' ? value.cursor : undefined, - nextCursor: typeof value.nextCursor === 'string' ? value.nextCursor : undefined, - expectedGenerations: value.expectedGenerations.map((entry) => ({ ...entry })), - items: value.items.map((item) => ({ - ...item, - itemRef: { ...item.itemRef }, - riskReasonCodes: [...item.riskReasonCodes], - })), - } as ExternalApplicationReviewPageV2; -} - -export function normalizeExternalApplicationControlResultV2( - value: unknown, -): ExternalApplicationControlResultV2 { - if (!isExactRecord(value, [ - 'schemaVersion', - 'operationId', - 'preferenceRevision', - 'outcome', - 'itemResults', - ]) - || value.schemaVersion !== 2 - || typeof value.operationId !== 'string' - || !isNonNegativeInteger(value.preferenceRevision) - || !isOneOf(value.outcome, APPLICATION_OPERATION_OUTCOMES) - || !Array.isArray(value.itemResults) - || !value.itemResults.every((result) => ( - isExactRecord( - result, - ['itemRef', 'outcome', 'reasonCode', 'recoveryActions'], - ['itemRef', 'outcome', 'recoveryActions'], - ) - && isApplicationReviewItemRef(result.itemRef) - && isOneOf(result.outcome, APPLICATION_OPERATION_OUTCOMES) - && (result.reasonCode === undefined - || result.reasonCode === null - || typeof result.reasonCode === 'string') - && Array.isArray(result.recoveryActions) - && result.recoveryActions.every(isApplicationRecoveryAction) - ))) { - throw new ExternalSourceApiError( - 'invalid_response', - 'External application V2 action result schema was invalid', - false, - ); - } - return { - ...value, - itemResults: value.itemResults.map((result) => ({ - ...result, - itemRef: { ...result.itemRef }, - reasonCode: typeof result.reasonCode === 'string' ? result.reasonCode : undefined, - recoveryActions: [...result.recoveryActions], - })), - } as ExternalApplicationControlResultV2; -} - function isHostCapabilities( value: unknown, ): value is ExternalSourceCatalogSnapshot['hostCapabilities'] { @@ -1934,85 +1322,6 @@ function emitExternalAgentCatalogUpdated(workspacePath?: string) { } export const externalSourcesAPI = { - async getApplicationReviewPage( - workspacePath: string | undefined, - request: ExternalApplicationReviewPageRequestV2, - ) { - const page = normalizeExternalApplicationReviewPageV2( - await invokeExternalSourceCommand( - 'get_external_application_review_page_v2', - { - request: { - workspacePath: normalizeOptionalWorkspacePath(workspacePath), - request, - }, - }, - ), - ); - const openingRequest = request.cursor === undefined - && request.expectedGenerations.length === 0; - if (page.executionDomainId !== request.executionDomainId - || page.workspaceScopeId !== request.workspaceScopeId - || page.targetScope !== request.targetScope - || page.preferenceRevision !== request.preferenceRevision - || page.cursor !== request.cursor - || (!openingRequest && page.reviewId !== request.reviewId)) { - throw new ExternalSourceApiError( - 'invalid_response', - 'External application V2 review page did not match its request', - false, - ); - } - return page; - }, - - async applyApplicationAction( - workspacePath: string | undefined, - request: ExternalApplicationControlRequestV2, - ) { - const result = normalizeExternalApplicationControlResultV2( - await invokeExternalSourceCommand( - 'apply_external_application_action_v2', - { - request: { - workspacePath: normalizeOptionalWorkspacePath(workspacePath), - request, - }, - }, - ), - ); - if (result.operationId !== request.operationId) { - throw new ExternalSourceApiError( - 'invalid_response', - 'External application V2 action result did not match its operation', - false, - ); - } - return result; - }, - - async getApplicationSurface(workspacePath?: string, forceRefresh = false) { - const request = { - workspacePath: normalizeOptionalWorkspacePath(workspacePath), - forceRefresh, - }; - try { - const snapshot = normalizeExternalApplicationSnapshotV2( - await invokeExternalSourceCommand( - 'get_external_application_snapshot_v2', - { request }, - ), - ); - return { protocol: 'v2' as const, snapshot }; - } catch (error) { - if (!(error instanceof ExternalSourceApiError) || error.code !== 'incompatible_version') { - throw error; - } - const snapshot = (await invokeCompatibleSurfaceSnapshot({ request })).catalog; - return { protocol: 'v1' as const, snapshot }; - } - }, - planMcpImport(workspacePath?: string) { return invokeExternalSourceCommand( 'plan_external_mcp_import_command', diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts index 354f94e23..30c96a071 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts @@ -33,15 +33,9 @@ export const externalSourcesConfigAppearanceDescriptor: AppearanceSurfaceDescrip { id: 'ecosystemHeading' }, { id: 'ecosystemName' }, { id: 'ecosystemState' }, - { id: 'attentionSummary' }, { id: 'application' }, - { id: 'applicationFacts' }, + { id: 'appAttention' }, { id: 'applicationToggle' }, - { id: 'appCapabilities' }, - { id: 'appCapability' }, - { id: 'reviewItem' }, - { id: 'loadMoreReview' }, - { id: 'submitReview' }, { id: 'hooksSection' }, { id: 'hooksSummary' }, ], diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index 998faf596..a42f0550c 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -110,55 +110,6 @@ padding-left: var(--bf-appearance-token-size-gap-4); } - &__app-detail { - display: grid; - gap: var(--bf-appearance-token-size-gap-4); - } - - &__app-detail-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--bf-appearance-token-size-gap-4); - - h2 { margin: 0; color: var(--bf-appearance-token-color-text-primary); font-size: 20px; } - p { margin: 5px 0 0; color: var(--bf-appearance-token-color-text-secondary); font-size: 12px; } - small { display: block; margin-top: 4px; color: var(--bf-appearance-token-color-text-muted); font-size: 11px; } - } - - &__app-attention { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--bf-appearance-token-size-gap-3); - width: 100%; - padding: 12px 14px; - border: 1px solid color-mix(in srgb, var(--bf-appearance-token-color-warning) 45%, transparent); - border-radius: var(--bf-appearance-token-size-radius-sm); - color: var(--bf-appearance-token-color-warning); - background: color-mix(in srgb, var(--bf-appearance-token-color-warning) 7%, transparent); - text-align: left; - cursor: pointer; - - small { display: block; margin-top: 4px; color: var(--bf-appearance-token-color-text-secondary); } - } - - &__app-capabilities { overflow: hidden; border: 1px solid var(--bf-appearance-token-border-subtle); border-radius: var(--bf-appearance-token-size-radius-md); } - &__app-capability { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 13px 14px; - border-bottom: 1px solid var(--bf-appearance-token-border-subtle); - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - - &:last-child { border-bottom: 0; } - strong, small { display: block; } - strong { color: var(--bf-appearance-token-color-text-primary); font-size: 13px; } - small { margin-top: 3px; } - } &__app-list { display: grid; overflow: hidden; @@ -166,208 +117,66 @@ border-radius: var(--bf-appearance-token-size-radius-md); } - &__review { - display: block; - } - - &__review-toolbar { - display: flex; - align-items: center; - gap: var(--bf-appearance-token-size-gap-3); - padding: var(--bf-appearance-token-size-gap-2) var(--bf-appearance-token-size-gap-4); - border-bottom: 1px solid var(--bf-appearance-token-border-subtle); - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - } - - &__review-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - align-items: center; - gap: var(--bf-appearance-token-size-gap-2); - } - - &__review-loading { - color: var(--bf-appearance-token-color-text-secondary); - } - - &__review-adjustments { - padding: 0 var(--bf-appearance-token-size-gap-4) var(--bf-appearance-token-size-gap-4); - border-top: 1px solid var(--bf-appearance-token-border-subtle); - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - - > summary { - width: fit-content; - padding-top: var(--bf-appearance-token-size-gap-3); - cursor: pointer; - - &:focus-visible { - outline: 2px solid var(--bf-appearance-token-color-accent-500); - outline-offset: 2px; - } - } - - &[open] > summary { margin-bottom: var(--bf-appearance-token-size-gap-3); } - - > .bitfun-external-sources-config__review-actions { - margin-top: var(--bf-appearance-token-size-gap-3); - } - } - - &__review .bitfun-external-sources-config__app-row { - cursor: pointer; - - > input { flex-shrink: 0; } - > .bitfun-external-sources-config__app-copy { flex: 1; } - } - - &__attention-summary { - display: flex; - align-items: center; - justify-content: flex-start; - gap: var(--bf-appearance-token-size-gap-3); - width: 100%; - margin-bottom: var(--bf-appearance-token-size-gap-3); - padding: 12px 14px; - border: 1px solid color-mix(in srgb, var(--bf-appearance-token-color-warning) 45%, transparent); - border-radius: var(--bf-appearance-token-size-radius-sm); - color: var(--bf-appearance-token-color-warning); - background: color-mix(in srgb, var(--bf-appearance-token-color-warning) 7%, transparent); - text-align: left; - cursor: pointer; - - &:focus-visible { - outline: 2px solid var(--bf-appearance-token-color-accent-500); - outline-offset: 2px; - } - } - &__app-row { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; - justify-content: space-between; gap: var(--bf-appearance-token-size-gap-3); - padding: 12px var(--bf-appearance-token-size-gap-4); + min-height: 48px; + padding: 10px var(--bf-appearance-token-size-gap-4); border-bottom: 1px solid var(--bf-appearance-token-border-subtle); &:last-child { border-bottom: 0; } } - &__app-expand { + &__app-name { + min-width: 0; + overflow: hidden; + color: var(--bf-appearance-token-color-text-primary); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__app-attention { display: grid; - flex-shrink: 0; - width: 26px; - height: 26px; + width: 28px; + height: 28px; padding: 0; place-items: center; border: 0; - border-radius: 6px; - color: var(--bf-appearance-token-color-text-secondary); + border-radius: var(--bf-appearance-token-size-radius-sm); + color: var(--bf-appearance-token-color-warning); background: transparent; cursor: pointer; - &:hover, - &:focus-visible, - &[aria-expanded='true'] { - color: var(--bf-appearance-token-color-text-primary); - background: var(--bf-appearance-token-color-bg-secondary); - } - + &:hover { background: var(--bf-appearance-token-color-bg-secondary); } &:focus-visible { outline: 2px solid var(--bf-appearance-token-color-accent-500); outline-offset: 1px; } } - &__app-facts { - display: inline-flex; - align-items: center; - color: var(--bf-appearance-token-color-warning); - - &:focus-visible { - outline: 2px solid var(--bf-appearance-token-color-accent-500); - outline-offset: 2px; - border-radius: 2px; - } - } - &__app-toggle { flex-shrink: 0; - } - - &__app-capabilities { - overflow: hidden; - padding: 0 var(--bf-appearance-token-size-gap-6); - border-bottom: 1px solid var(--bf-appearance-token-border-subtle); - - &:last-child { border-bottom: 0; } - } - - &__app-capability { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 11px 0; - border-bottom: 1px solid var(--bf-appearance-token-border-subtle); - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - - &:last-child { border-bottom: 0; } - strong, small { display: block; } - strong { color: var(--bf-appearance-token-color-text-primary); font-size: 13px; } - small { margin-top: 3px; } - } - - &__app-capability-access { - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - } - &__app-capability-empty { - padding: 11px 0; - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - } - - &__app-capability-manage { - display: inline-flex; - align-items: center; - gap: 6px; - margin: 10px 0; - padding: 0; - border: 0; - color: var(--bf-appearance-token-color-accent-500); - background: transparent; - font: inherit; - cursor: pointer; + &[role='button'] { + border-radius: var(--bf-appearance-token-size-radius-sm); + cursor: pointer; + } - &:hover { text-decoration: underline; } - &:focus-visible { + &[role='button']:focus-visible { outline: 2px solid var(--bf-appearance-token-color-accent-500); outline-offset: 2px; - border-radius: 2px; } } - &__app-copy { min-width: 0; } - &__app-heading { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--bf-appearance-token-size-gap-2); - } - &__app-name { color: var(--bf-appearance-token-color-text-primary); font-weight: 600; } - &__app-status { + &__app-empty { + padding: 12px var(--bf-appearance-token-size-gap-4); color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - } - &__app-status { - &.is-connected, &.is-connected_custom { color: var(--bf-appearance-token-color-success); } - &.is-needs_attention { color: var(--bf-appearance-token-color-warning); } + font-size: 13px; } + &__ecosystem-heading, &__policy-actions, &__ecosystem-name { @@ -890,35 +699,6 @@ font-size: 12px; } - &__review-summary { - display: flex; - flex-wrap: wrap; - gap: 4px 12px; - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - } - - &__review-risk { - margin-top: 6px; - } - - &__review-details { - margin-top: 8px; - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - - > summary { - width: fit-content; - color: var(--bf-appearance-token-color-accent-500); - cursor: pointer; - user-select: none; - } - - &[open] > summary { - margin-bottom: 8px; - } - } - &__diagnostic-code { color: var(--bf-appearance-token-color-text-muted); font-size: 12px; @@ -972,10 +752,6 @@ } @container external-sources (max-width: 720px) { - &__review-decision .bitfun-config-page-row__control { - justify-content: flex-start; - } - &__source-group.bitfun-config-page-row { grid-template-columns: minmax(0, 1fr); gap: var(--bf-appearance-token-size-gap-2); diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 5c5254723..4514f3967 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -8,9 +8,6 @@ import ExternalSourcesConfig from './ExternalSourcesConfig'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; const getSnapshotMock = vi.hoisted(() => vi.fn()); -const getApplicationSurfaceMock = vi.hoisted(() => vi.fn()); -const getApplicationReviewPageMock = vi.hoisted(() => vi.fn()); -const applyApplicationActionMock = vi.hoisted(() => vi.fn()); const hookPanelMountedMock = vi.hoisted(() => vi.fn()); const setSourceEnabledMock = vi.hoisted(() => vi.fn()); const setSafeModeMock = vi.hoisted(() => vi.fn()); @@ -74,9 +71,6 @@ vi.mock('@/shared/types', () => ({ vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ externalSourcesAPI: { getSnapshot: getSnapshotMock, - getApplicationSurface: getApplicationSurfaceMock, - getApplicationReviewPage: getApplicationReviewPageMock, - applyApplicationAction: applyApplicationActionMock, setSourceEnabled: setSourceEnabledMock, setSafeMode: setSafeModeMock, setConflictChoice: setConflictChoiceMock, @@ -248,45 +242,6 @@ const integrationPolicy = { }], }; -const applicationSnapshotV2 = { - schemaVersion: 2 as const, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - effectiveConnectionScope: 'workspace_override' as const, - refreshGeneration: 7, - preferenceRevision: 11, - safeMode: false, - hostCapabilities: { - canReadSnapshot: true, - canReadReview: true, - canMutate: true, - canManageUserDefault: true, - canManageWorkspaceOverride: true, - canRefresh: true, - canSetSafeMode: true, - }, - applications: [{ - applicationId: 'opencode', - ecosystemId: 'opencode', - displayName: 'OpenCode', - discovery: 'discovered' as const, - connection: 'disconnected' as const, - desiredConnection: 'unspecified' as const, - health: 'healthy' as const, - effectiveStatus: 'configuration_available' as const, - primaryAction: 'connect' as const, - defaultConnectionPolicy: 'connect' as const, - defaultConnectionReason: 'supported_by_product', - enabledCount: 0, - pendingReviewCount: 0, - blockedCount: 0, - conflictCount: 0, - riskSummary: { reasonCodes: [] }, - userDecision: 'none' as const, - recoveryActions: [], - }], -}; - describe('ExternalSourcesConfig', () => { let container: HTMLDivElement; let root: Root; @@ -297,17 +252,6 @@ describe('ExternalSourcesConfig', () => { workspaceState.kind = 'normal'; peerState.deviceId = ''; getSnapshotMock.mockResolvedValue(snapshot); - getApplicationSurfaceMock.mockImplementation(async (...args: unknown[]) => ({ - protocol: 'v1', - snapshot: await getSnapshotMock(...args), - })); - applyApplicationActionMock.mockResolvedValue({ - schemaVersion: 2, - operationId: 'operation-result', - preferenceRevision: 12, - outcome: 'applied', - itemResults: [], - }); setSourceEnabledMock.mockResolvedValue(snapshot); setSafeModeMock.mockResolvedValue(snapshot); setConflictChoiceMock.mockResolvedValue({ @@ -371,11 +315,30 @@ describe('ExternalSourcesConfig', () => { }); }); - it('shows one review entry and keeps advanced capability controls collapsed by default', async () => { + it('opens the existing owner controls from the application permission hint', async () => { + const scrolledElements: Element[] = []; + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value(this: Element) { + scrolledElements.push(this); + }, + }); const policySnapshot = { ...snapshot, preferenceRevision: 4, integrationPolicy, + control: { + schemaVersion: 1 as const, + executionDomainId: 'local-user', + refreshGeneration: 1, + preferenceRevision: 4, + safeMode: true, + hostCapabilities: snapshot.hostCapabilities, + sources: [], + capabilities: [], + diagnostics: [], + recoveryActions: [], + }, sources: [{ ...snapshot.sources[0], record: { @@ -408,16 +371,24 @@ describe('ExternalSourcesConfig', () => { await Promise.resolve(); }); - expect(container.querySelectorAll('[data-bf-part="attentionSummary"]')).toHaveLength(1); - expect(container.textContent).toContain('applications.review.title'); + expect(container.querySelectorAll('[data-bf-part="appAttention"]')).toHaveLength(1); const advanced = container.querySelector( '.bitfun-external-sources-config__advanced', ); expect(advanced?.open).toBe(false); - const openReview = container.querySelector('[data-bf-part="attentionSummary"]'); - await act(async () => openReview?.click()); + const openPermissions = container.querySelector('[data-bf-part="appAttention"]'); + await act(async () => { + openPermissions?.click(); + await vi.runAllTimersAsync(); + }); expect(advanced?.open).toBe(true); + const matchingApplicationAction = container.querySelector( + '[data-bf-part="toolCard"][data-external-attention="true"]' + + '[data-external-ecosystem="opencode"]', + ); + expect(matchingApplicationAction).not.toBeNull(); + expect(scrolledElements).toContain(matchingApplicationAction); }); it('uses each application switch as the recommended connection control without a dialog', async () => { @@ -481,814 +452,47 @@ describe('ExternalSourcesConfig', () => { }); }); - it('renders V2 Host application state and uses only the Host connect action', async () => { - getApplicationSurfaceMock.mockResolvedValue({ - protocol: 'v2', - snapshot: { ...applicationSnapshotV2, effectiveConnectionScope: 'user_default' }, - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('applications.status.configuration_available'); - expect(container.textContent).not.toContain('applications.summary.enabledCount'); - expect(container.textContent).not.toContain('hooksManagement.description'); - expect(container.textContent).not.toContain('applications.advanced.description'); - const hooksSummary = container.querySelector( - '.bitfun-external-sources-config__hooks-summary', - ); - expect(hooksSummary?.getAttribute('aria-expanded')).toBe('false'); - expect( - hooksSummary?.querySelector('.bitfun-external-sources-config__disclosure-icon'), - ).not.toBeNull(); - const advanced = container.querySelector( - '.bitfun-external-sources-config__advanced', - ); - const advancedSummary = advanced?.querySelector('summary'); - expect(advanced?.open).toBe(false); - expect(advancedSummary?.getAttribute('aria-expanded')).toBe('false'); - expect( - advancedSummary?.querySelector('.bitfun-external-sources-config__disclosure-icon'), - ).not.toBeNull(); - expect(advanced?.textContent).toContain('safeMode.title'); - await act(async () => { - advancedSummary?.click(); - advanced?.dispatchEvent(new Event('toggle')); - await Promise.resolve(); - }); - expect(advanced?.open).toBe(true); - expect(advancedSummary?.getAttribute('aria-expanded')).toBe('true'); - expect(container.textContent).toContain('safeMode.title'); - const applicationToggle = container.querySelector( - '[data-bf-part="applicationToggle"] input[type="checkbox"]', - ); - expect(applicationToggle?.checked).toBe(false); - - await act(async () => { - applicationToggle?.click(); - await Promise.resolve(); - }); - - expect(applyApplicationActionMock).toHaveBeenCalledWith( - 'D:/workspace/project', - expect.objectContaining({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - expectedPreferenceRevision: 11, - action: { type: 'connect_application', applicationId: 'opencode' }, - }), - ); - expect(updateIntegrationPolicyMock).not.toHaveBeenCalled(); - }); - - it('keeps the refreshed V2 review current while loading the V1 compatibility catalog', async () => { - let generation = 7; - const reviewSnapshot = () => ({ - ...applicationSnapshotV2, - refreshGeneration: generation, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention' as const, - primaryAction: 'review' as const, - pendingReviewCount: 1, - }], - reviewSummary: { - reviewId: `review-${generation}`, - totalCount: 1, - categoryCounts: [{ kind: 'tool' as const, count: 1 }], - maxSelectionCount: 1, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 1, optionalCount: 0, blockedCount: 0 }, - safetyCeiling: 'automatic' as const, - }, - }); - getApplicationSurfaceMock.mockImplementation(async ( - _workspacePath: string, - forceRefresh: boolean, - ) => { - if (forceRefresh) generation += 1; - return { protocol: 'v2', snapshot: reviewSnapshot() }; - }); - getSnapshotMock.mockImplementation(async ( - _workspacePath: string, - forceRefresh: boolean, - ) => { - if (forceRefresh) generation += 1; - return { ...snapshot, generation }; - }); - getApplicationReviewPageMock.mockImplementation(async ( - _workspacePath: string, - request: { reviewId: string }, - ) => { - if (request.reviewId !== `review-${generation}`) { - throw new Error('stale review'); - } - return { - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: request.reviewId, - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation }], - totalCount: 1, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-current' }, - displayName: 'Current Tool', - displaySummary: 'Current review item', - riskLevel: 'low', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }; - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('button[aria-label="actions.refresh"]')?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('Current Tool'); - expect(getSnapshotMock).toHaveBeenLastCalledWith('D:/workspace/project', false); - }); - - it('opens the authoritative first review page when discovery settles after the snapshot', async () => { - const currentSnapshot = { - ...applicationSnapshotV2, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention' as const, - primaryAction: 'review' as const, - pendingReviewCount: 2, - }], - reviewSummary: { - reviewId: 'review-current', - totalCount: 2, - categoryCounts: [{ kind: 'tool' as const, count: 2 }], - maxSelectionCount: 2, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 0, optionalCount: 2, blockedCount: 0 }, - safetyCeiling: 'automatic' as const, - }, - }; - getApplicationSurfaceMock.mockResolvedValueOnce({ - protocol: 'v2', - snapshot: { - ...applicationSnapshotV2, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention', - primaryAction: 'review', - pendingReviewCount: 1, - }], - reviewSummary: { - reviewId: 'review-stale', - totalCount: 1, - categoryCounts: [{ kind: 'conflict', count: 1 }], - maxSelectionCount: 0, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 0, optionalCount: 0, blockedCount: 1 }, - safetyCeiling: 'blocked', - }, - }, - }).mockResolvedValue({ protocol: 'v2', snapshot: currentSnapshot }); - getApplicationReviewPageMock.mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-current', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 8 }], - nextCursor: 'page-2', - totalCount: 2, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-current' }, - displayName: 'Current Tool', - displaySummary: 'Current review item', - riskLevel: 'low', - riskReasonCodes: [], - recommended: false, - safetyCeiling: 'automatic', - }], - }).mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-current', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 8 }], - cursor: 'page-2', - totalCount: 2, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-next' }, - displayName: 'Next Tool', - displaySummary: 'Next review item', - riskLevel: 'moderate', - riskReasonCodes: [], - recommended: false, - safetyCeiling: 'review_required', - }], - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('Current Tool'); - expect(container.querySelector('input[type="checkbox"]')?.disabled).toBe(false); - await act(async () => { - container.querySelector('[data-bf-part="loadMoreReview"]')?.click(); - await Promise.resolve(); - }); - expect(container.textContent).toContain('Next Tool'); - expect(getApplicationReviewPageMock).toHaveBeenLastCalledWith( - 'D:/workspace/project', - expect.objectContaining({ - reviewId: 'review-current', - expectedGenerations: [{ owner: 'tool', generation: 8 }], - cursor: 'page-2', - }), - ); - }); - - it('returns to the application list when the first review page cannot be loaded', async () => { - getApplicationSurfaceMock.mockResolvedValue({ - protocol: 'v2', - snapshot: { - ...applicationSnapshotV2, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention', - primaryAction: 'review', - pendingReviewCount: 1, - }], - reviewSummary: { - reviewId: 'review-a', - totalCount: 1, - categoryCounts: [{ kind: 'tool', count: 1 }], - maxSelectionCount: 1, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 1, optionalCount: 0, blockedCount: 0 }, - safetyCeiling: 'automatic', - }, - }, - }); - getApplicationReviewPageMock.mockRejectedValue(Object.assign(new Error('stale review'), { - code: 'stale_revision', - recoveryActions: [{ type: 'refresh' }], - })); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(container.querySelector('[data-bf-part="application"]')).not.toBeNull(); - expect(container.querySelector('.bitfun-external-sources-config__review')).toBeNull(); - expect(container.textContent).toContain('operationErrors.refreshRequired'); - expect(container.textContent).toContain('recoveryActions.refresh'); - }); - - it('does not regress a V2 Host generation on a later refresh response', async () => { - const connected = { - ...applicationSnapshotV2, - refreshGeneration: 8, - applications: [{ - ...applicationSnapshotV2.applications[0], - connection: 'connected' as const, - effectiveStatus: 'connected' as const, - primaryAction: 'view' as const, - }], - }; - const regressed = { - ...applicationSnapshotV2, - refreshGeneration: 7, - applications: [{ - ...applicationSnapshotV2.applications[0], - discovery: 'not_discovered' as const, - effectiveStatus: 'no_configuration' as const, - primaryAction: 'none' as const, - }], - }; - getApplicationSurfaceMock - .mockResolvedValueOnce({ protocol: 'v2', snapshot: connected }) - .mockResolvedValueOnce({ protocol: 'v2', snapshot: regressed }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - window.dispatchEvent(new Event('focus')); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('applications.status.connected'); - expect(container.textContent).not.toContain('applications.status.no_configuration'); - }); - - it('does not admit a review page after its Host review identity is replaced', async () => { - const summary = { - reviewId: 'review-a', - totalCount: 1, - categoryCounts: [{ kind: 'tool' as const, count: 1 }], - maxSelectionCount: 1, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 1, optionalCount: 0, blockedCount: 0 }, - safetyCeiling: 'automatic' as const, - }; - getApplicationSurfaceMock - .mockResolvedValueOnce({ - protocol: 'v2', - snapshot: { ...applicationSnapshotV2, reviewSummary: summary }, - }) - .mockResolvedValueOnce({ - protocol: 'v2', - snapshot: { - ...applicationSnapshotV2, - refreshGeneration: 8, - preferenceRevision: 12, - reviewSummary: { ...summary, reviewId: 'review-b', totalCount: 0 }, - }, - }); - let resolveReviewPage: ((value: Record) => void) | undefined; - getApplicationReviewPageMock.mockReturnValue(new Promise((resolve) => { - resolveReviewPage = resolve; - })); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - }); - await act(async () => { - window.dispatchEvent(new Event('focus')); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - resolveReviewPage?.({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - totalCount: 1, - items: [{ - itemRef: { kind: 'tool', stableId: 'old-tool' }, - displayName: 'Old Tool', - displaySummary: 'Stale page', - riskLevel: 'low', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }); - await Promise.resolve(); - }); - - expect(container.textContent).not.toContain('Old Tool'); - }); - - it('uses the V2 Host safe-mode state and mutation instead of the legacy projection', async () => { - getApplicationSurfaceMock.mockResolvedValue({ - protocol: 'v2', - snapshot: { ...applicationSnapshotV2, safeMode: true }, - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('safeMode.activeNotice'); - const safeModeToggle = container.querySelector( - 'input[aria-label="safeMode.toggleLabel"]', - ); - expect(safeModeToggle?.checked).toBe(true); - await act(async () => { - safeModeToggle?.click(); - await Promise.resolve(); - }); - expect(applyApplicationActionMock).toHaveBeenCalledWith( - 'D:/workspace/project', - expect.objectContaining({ action: { type: 'set_safe_mode', enabled: false } }), - ); - expect(setSafeModeMock).not.toHaveBeenCalled(); - }); - - it('reviews V2 items with a recommended baseline and bounded advanced overrides', async () => { - const reviewSnapshot = { - ...applicationSnapshotV2, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention' as const, - primaryAction: 'review' as const, - pendingReviewCount: 2, - }], - reviewSummary: { - reviewId: 'review-a', - totalCount: 2, - categoryCounts: [{ kind: 'tool' as const, count: 2 }], - maxSelectionCount: 2, - riskSummary: { highestLevel: 'moderate' as const, reasonCodes: [] }, - recommendationSummary: { - recommendedCount: 1, - optionalCount: 1, - blockedCount: 0, - }, - safetyCeiling: 'review_required' as const, + it('focuses the master setting when an application switch is disabled by it', async () => { + const scrolledElements: Element[] = []; + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value(this: Element) { + scrolledElements.push(this); }, - }; - getApplicationSurfaceMock - .mockResolvedValueOnce({ protocol: 'v2', snapshot: reviewSnapshot }) - .mockResolvedValue({ - protocol: 'v2', - snapshot: { - ...reviewSnapshot, - preferenceRevision: 12, - refreshGeneration: 8, - reviewSummary: undefined, - }, - }); - getApplicationReviewPageMock.mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - nextCursor: 'page-2', - totalCount: 2, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-a' }, - displayName: 'Tool A', - displaySummary: 'Read repository files', - riskLevel: 'low', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }).mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - cursor: 'page-2', - totalCount: 2, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-b' }, - displayName: 'Tool B', - displaySummary: 'Run a local process', - riskLevel: 'moderate', - riskReasonCodes: ['process_execution'], - recommended: false, - safetyCeiling: 'review_required', - }], - }); - applyApplicationActionMock.mockResolvedValueOnce({ - schemaVersion: 2, - operationId: 'operation-review', - preferenceRevision: 12, - outcome: 'applied', - itemResults: [{ - itemRef: { kind: 'tool', stableId: 'tool-b' }, - outcome: 'rejected', - reasonCode: 'runtime_unavailable', - recoveryActions: [{ type: 'install_runtime' }], - }], - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - const reviewEntry = container.querySelector( - '[data-bf-part="attentionSummary"]', - ); - expect(container.querySelectorAll('[data-bf-part="attentionSummary"]')).toHaveLength(1); - await act(async () => { - reviewEntry?.click(); - await Promise.resolve(); - }); - - expect(getApplicationReviewPageMock).toHaveBeenCalledWith( - 'D:/workspace/project', - expect.objectContaining({ - reviewId: 'review-a', - targetScope: 'workspace_override', - expectedGenerations: [], - pageSize: 64, - }), - ); - await act(async () => { - container.querySelector( - '.bitfun-external-sources-config__review-adjustments', - )?.querySelector('summary')?.click(); - }); - const selections = container.querySelectorAll( - '[data-bf-part="reviewItem"] input[type="checkbox"]', - ); - expect(selections).toHaveLength(1); - const loadMore = container.querySelector('[data-bf-part="loadMoreReview"]'); - await act(async () => { - loadMore?.click(); - await Promise.resolve(); - }); - const pagedSelections = container.querySelectorAll( - '[data-bf-part="reviewItem"] input[type="checkbox"]', - ); - expect(pagedSelections).toHaveLength(2); - expect(pagedSelections[0].checked).toBe(true); - expect(pagedSelections[1].checked).toBe(false); - expect(getApplicationReviewPageMock).toHaveBeenLastCalledWith( - 'D:/workspace/project', - expect.objectContaining({ - cursor: 'page-2', - expectedGenerations: [{ owner: 'tool', generation: 7 }], - }), - ); - await act(async () => pagedSelections[1].click()); - - const submit = container.querySelector( - '[data-bf-part="submitReview"][data-review-baseline="recommended"]', - ); - await act(async () => { - submit?.click(); - await Promise.resolve(); - await Promise.resolve(); }); - - expect(applyApplicationActionMock).toHaveBeenCalledWith( - 'D:/workspace/project', - expect.objectContaining({ - action: { - type: 'submit_application_review', - reviewId: 'review-a', - expectedGenerations: [{ owner: 'tool', generation: 7 }], - selectionBaseline: 'recommended', - selectionOverrides: [{ - itemRef: { kind: 'tool', stableId: 'tool-b' }, - selected: true, - }], - }, - }), - ); - expect(container.textContent).toContain('applications.review.outcome.partial'); - expect(container.textContent).toContain('applications.review.itemOutcome.rejected'); - expect(container.textContent).toContain('"selected":2,"maximum":2'); - }); - - it('lets the user decline every pending item without editing individual choices', async () => { - const reviewSnapshot = { - ...applicationSnapshotV2, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention' as const, - primaryAction: 'review' as const, - pendingReviewCount: 1, - }], - reviewSummary: { - reviewId: 'review-decline', - totalCount: 1, - categoryCounts: [{ kind: 'tool' as const, count: 1 }], - maxSelectionCount: 1, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { - recommendedCount: 1, - optionalCount: 0, - blockedCount: 0, - }, - safetyCeiling: 'automatic' as const, + getSnapshotMock.mockResolvedValue({ + ...snapshot, + integrationPolicy: { + ...integrationPolicy, + userDefaults: { ...integrationPolicy.userDefaults, enabled: false }, + globalEffective: { ...integrationPolicy.globalEffective, enabled: false }, + effective: { ...integrationPolicy.effective, enabled: false }, }, - }; - getApplicationSurfaceMock.mockResolvedValue({ protocol: 'v2', snapshot: reviewSnapshot }); - getApplicationReviewPageMock.mockResolvedValue({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-decline', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - totalCount: 1, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-a' }, - displayName: 'Tool A', - displaySummary: 'Run a local process', - riskLevel: 'moderate', - riskReasonCodes: ['process_execution'], - recommended: true, - safetyCeiling: 'review_required', + sources: [{ + ...snapshot.sources[0], + record: { ...snapshot.sources[0].record, ecosystemId: 'opencode' }, }], + commandConflicts: [], + diagnostics: [], }); await act(async () => { root.render(); await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); }); - const decline = container.querySelector( - '[data-bf-part="submitReview"][data-review-baseline="none"]', + const disabledApplicationToggle = container.querySelector( + '[data-bf-part="applicationToggle"]', ); - expect(decline?.textContent).toBe('applications.review.doNotEnable'); await act(async () => { - decline?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(applyApplicationActionMock).toHaveBeenCalledWith( - 'D:/workspace/project', - expect.objectContaining({ - action: { - type: 'submit_application_review', - reviewId: 'review-decline', - expectedGenerations: [{ owner: 'tool', generation: 7 }], - selectionBaseline: 'none', - selectionOverrides: [], - }, - }), - ); - }); - - it('lets the user retry a review submission after a transport failure', async () => { - getApplicationSurfaceMock.mockResolvedValue({ - protocol: 'v2', - snapshot: { - ...applicationSnapshotV2, - reviewSummary: { - reviewId: 'review-retry', - totalCount: 1, - categoryCounts: [{ kind: 'tool' as const, count: 1 }], - maxSelectionCount: 1, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 1, optionalCount: 0, blockedCount: 0 }, - safetyCeiling: 'automatic' as const, - }, - }, + disabledApplicationToggle?.click(); + await vi.runAllTimersAsync(); }); - getApplicationReviewPageMock.mockResolvedValue({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-retry', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - totalCount: 1, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-a' }, - displayName: 'Tool A', - displaySummary: 'Read repository files', - riskLevel: 'low', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }); - applyApplicationActionMock.mockRejectedValueOnce(new Error('connection lost')); - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - }); - const submit = container.querySelector('[data-bf-part="submitReview"]'); - await act(async () => { - submit?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(submit?.disabled).toBe(false); - }); - - it('drops stale review cursors and generations before the user can review again', async () => { - const staleSnapshot = { - ...applicationSnapshotV2, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention' as const, - primaryAction: 'review' as const, - pendingReviewCount: 1, - }], - reviewSummary: { - reviewId: 'review-stale', - totalCount: 1, - categoryCounts: [{ kind: 'tool' as const, count: 1 }], - maxSelectionCount: 1, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 1, optionalCount: 0, blockedCount: 0 }, - safetyCeiling: 'automatic' as const, - }, - }; - getApplicationSurfaceMock.mockResolvedValue({ protocol: 'v2', snapshot: staleSnapshot }); - getApplicationReviewPageMock.mockResolvedValue({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-stale', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - totalCount: 1, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-a' }, - displayName: 'Tool A', - displaySummary: 'Read repository files', - riskLevel: 'low', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }); - applyApplicationActionMock.mockResolvedValueOnce({ - schemaVersion: 2, - operationId: 'operation-stale', - preferenceRevision: 12, - outcome: 'stale', - itemResults: [], - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="submitReview"]')?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('applications.review.outcome.stale'); - expect(container.querySelector('[data-bf-part="reviewItem"]')).toBeNull(); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - }); - expect(getApplicationReviewPageMock).toHaveBeenLastCalledWith( - 'D:/workspace/project', - expect.objectContaining({ expectedGenerations: [] }), - ); + const policyCard = container.querySelector('[data-bf-part="policyCard"]'); + const masterSwitch = policyCard?.querySelector('input[type="checkbox"]'); + expect(scrolledElements).toContain(policyCard); + expect(document.activeElement).toBe(masterSwitch); }); it('defers Hook owner reads until the Hook disclosure opens', async () => { diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 0cc504ff2..250ff624b 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -33,12 +33,6 @@ import { type ExternalIntegrationAccess, type ExternalIntegrationMode, type ExternalIntegrationPolicyMutation, - type ExternalApplicationControlActionV2, - type ExternalApplicationControlResultV2, - type ExternalApplicationOwnerGenerationV2, - type ExternalApplicationReviewItemResultV2, - type ExternalApplicationReviewItemV2, - type ExternalApplicationSnapshotV2, type ExternalMcpDefinition, type ExternalSourceCatalogSnapshot, type ExternalSourceRecoveryAction, @@ -69,7 +63,6 @@ import { ExternalCommandConflicts, ExternalSourceSection, buildExternalApplicationsView, - buildExternalApplicationsViewV2, type ExternalApplicationView, } from './external-sources'; import './ExternalSourcesConfig.scss'; @@ -134,32 +127,20 @@ type SnapshotLoadResult = | { status: 'ignored' } | { status: 'error' }; -type ApplicationReviewState = { - reviewId: string; - preferenceRevision: number; - loading: boolean; - items: ExternalApplicationReviewItemV2[]; - expectedGenerations: ExternalApplicationOwnerGenerationV2[]; - nextCursor?: string; - totalCount: number; - recommendedCount: number; - maxSelectionCount: number; - overrides: Record; - itemResults: ExternalApplicationReviewItemResultV2[]; - submitted: boolean; -}; - -let applicationOperationSequence = 0; - -function nextApplicationOperationId(): string { - const randomId = globalThis.crypto?.randomUUID?.(); - return randomId - ? `external-app-${randomId}` - : `external-app-${Date.now()}-${++applicationOperationSequence}`; +function sourceEcosystemId( + snapshot: ExternalSourceCatalogSnapshot | null, + source: { providerId: string; sourceId: string } | undefined, +): string | undefined { + if (!snapshot || !source) return undefined; + return snapshot.sources.find((candidate) => ( + candidate.record.key.providerId === source.providerId + && candidate.record.key.sourceId === source.sourceId + ))?.record.ecosystemId; } -function applicationReviewItemKey(item: ExternalApplicationReviewItemV2): string { - return `${item.itemRef.kind}:${item.itemRef.stableId}`; +function onlyEcosystemId(values: Array): string | undefined { + const ecosystems = new Set(values.filter((value): value is string => Boolean(value))); + return ecosystems.size === 1 ? ecosystems.values().next().value : undefined; } function abbreviatedLocation(location: string): string { @@ -425,11 +406,9 @@ const ExternalSourcesConfig: React.FC = ({ const [agentChangeNotice, setAgentChangeNotice] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); const [hooksOpen, setHooksOpen] = useState(initialFocus === 'hooks'); - const [applicationReviewState, setApplicationReviewState] = useState(null); const hooksSummaryRef = useRef(null); const handledHookFocusRequestRef = useRef(null); const snapshotRef = useRef(null); - const applicationSnapshotRef = useRef(null); const agentChangeNoticeRef = useRef(null); const requestSequence = useRef(0); const acceptedSequence = useRef(0); @@ -451,13 +430,6 @@ const ExternalSourcesConfig: React.FC = ({ snapshot: ExternalSourceCatalogSnapshot; } | null>(null); const snapshot = snapshotState?.scope === requestScope ? snapshotState.snapshot : null; - const [applicationSnapshotState, setApplicationSnapshotState] = useState<{ - scope: string; - snapshot: ExternalApplicationSnapshotV2; - } | null>(null); - const applicationSnapshot = applicationSnapshotState?.scope === requestScope - ? applicationSnapshotState.snapshot - : null; const requestScopeRef = useRef(requestScope); useLayoutEffect(() => { if (requestScopeRef.current !== requestScope) { @@ -465,7 +437,6 @@ const ExternalSourcesConfig: React.FC = ({ requestSequence.current += 1; acceptedSequence.current = requestSequence.current; snapshotRef.current = null; - applicationSnapshotRef.current = null; agentChangeNoticeRef.current = null; } }, [requestScope]); @@ -567,23 +538,6 @@ const ExternalSourcesConfig: React.FC = ({ return true; }, [applySnapshot]); - const acceptApplicationSnapshot = useCallback(( - next: ExternalApplicationSnapshotV2, - scope: string, - sequence: number, - ): boolean => { - if (requestScopeRef.current !== scope || sequence < acceptedSequence.current) return false; - if (Array.from(pendingMutations.current.values()).includes(scope)) return false; - const current = applicationSnapshotRef.current; - if (current?.executionDomainId === next.executionDomainId - && (next.refreshGeneration < current.refreshGeneration - || next.preferenceRevision < current.preferenceRevision)) return false; - acceptedSequence.current = sequence; - applicationSnapshotRef.current = next; - setApplicationSnapshotState({ scope, snapshot: next }); - return true; - }, []); - const acceptMutationSnapshot = useCallback(( next: ExternalSourceCatalogSnapshot, scope: string, @@ -608,26 +562,10 @@ const ExternalSourcesConfig: React.FC = ({ setRefreshing(true); } try { - const surface = await externalSourcesAPI.getApplicationSurface(workspacePath, forceRefresh); - if (surface.protocol === 'v1') { - if (!acceptReadSnapshot(surface.snapshot, scope, sequence)) return { status: 'ignored' }; - applicationSnapshotRef.current = null; - setApplicationSnapshotState(null); - setError(null); - return { status: 'accepted', snapshot: surface.snapshot }; - } - if (!acceptApplicationSnapshot(surface.snapshot, scope, sequence)) { - return { status: 'ignored' }; - } + const next = await externalSourcesAPI.getSnapshot(workspacePath, forceRefresh); + if (!acceptReadSnapshot(next, scope, sequence)) return { status: 'ignored' }; setError(null); - void externalSourcesAPI.getSnapshot(workspacePath, false) - .then((legacySnapshot) => { - acceptReadSnapshot(legacySnapshot, scope, sequence); - }) - .catch(() => { - // The V1 catalog is secondary on a V2 Host; the application home remains usable. - }); - return { status: 'accepted' }; + return { status: 'accepted', snapshot: next }; } catch (loadError) { if (requestScopeRef.current !== scope || sequence < acceptedSequence.current @@ -646,13 +584,10 @@ const ExternalSourcesConfig: React.FC = ({ } } } - }, [acceptApplicationSnapshot, acceptReadSnapshot, requestScope, workspacePath]); + }, [acceptReadSnapshot, requestScope, workspacePath]); useEffect(() => { setSnapshotState(null); - setApplicationSnapshotState(null); - applicationSnapshotRef.current = null; - setApplicationReviewState(null); snapshotRef.current = null; agentChangeNoticeRef.current = null; setAgentChangeNotice(null); @@ -727,259 +662,10 @@ const ExternalSourcesConfig: React.FC = ({ () => snapshot ? catalogDiagnosticsWithoutSourceDuplicates(snapshot, sourceGroups) : [], [snapshot, sourceGroups], ); - const applicationsView = useMemo( - () => applicationSnapshot - ? buildExternalApplicationsViewV2(applicationSnapshot) - : buildExternalApplicationsView(snapshot, sourceGroups, policyScope), - [applicationSnapshot, policyScope, snapshot, sourceGroups], + const applications = useMemo( + () => buildExternalApplicationsView(snapshot, policyScope), + [policyScope, snapshot], ); - const applicationTargetScope = applicationSnapshot?.workspaceScopeId - ? 'workspace_override' - : 'user_default'; - const canMutateApplicationScope = applicationSnapshot - ? applicationSnapshot.hostCapabilities.canMutate - && (applicationTargetScope === 'workspace_override' - ? applicationSnapshot.hostCapabilities.canManageWorkspaceOverride - : applicationSnapshot.hostCapabilities.canManageUserDefault) - : false; - - const loadApplicationReviewPage = useCallback(async (cursor?: string) => { - const current = applicationSnapshot; - const summary = current?.reviewSummary; - if (!current || !summary || !current.hostCapabilities.canReadReview) return; - const scope = requestScope; - const append = cursor !== undefined; - const reviewId = append ? applicationReviewState?.reviewId : summary.reviewId; - if (!reviewId) return; - const preferenceRevision = append - ? applicationReviewState?.preferenceRevision ?? current.preferenceRevision - : current.preferenceRevision; - const expectedGenerations = append - ? applicationReviewState?.expectedGenerations ?? [] - : []; - setApplicationReviewState((previous) => ({ - reviewId, - preferenceRevision, - loading: true, - items: append && previous?.reviewId === reviewId ? previous.items : [], - expectedGenerations, - nextCursor: append ? previous?.nextCursor : undefined, - totalCount: summary.totalCount, - recommendedCount: summary.recommendationSummary.recommendedCount, - maxSelectionCount: summary.maxSelectionCount, - overrides: append && previous?.reviewId === reviewId ? previous.overrides : {}, - itemResults: append && previous?.reviewId === reviewId ? previous.itemResults : [], - submitted: append && previous?.reviewId === reviewId ? previous.submitted : false, - })); - try { - const page = await externalSourcesAPI.getApplicationReviewPage(workspacePath, { - schemaVersion: 2, - executionDomainId: current.executionDomainId, - ...(current.workspaceScopeId ? { workspaceScopeId: current.workspaceScopeId } : {}), - targetScope: current.workspaceScopeId ? 'workspace_override' : 'user_default', - reviewId, - preferenceRevision, - expectedGenerations, - ...(cursor ? { cursor } : {}), - pageSize: 64, - }); - let authoritativeSummary = summary; - let reboundSnapshot: ExternalApplicationSnapshotV2 | null = null; - if (!append && page.reviewId !== summary.reviewId) { - const surface = await externalSourcesAPI.getApplicationSurface(workspacePath, false); - if (surface.protocol !== 'v2') { - throw new Error('The current Host no longer supports application review.'); - } - const reboundSummary = surface.snapshot.reviewSummary; - if (!reboundSummary - || surface.snapshot.executionDomainId !== page.executionDomainId - || surface.snapshot.workspaceScopeId !== page.workspaceScopeId - || surface.snapshot.preferenceRevision !== page.preferenceRevision - || reboundSummary.reviewId !== page.reviewId) { - throw new Error('The application review changed while it was opening.'); - } - authoritativeSummary = reboundSummary; - reboundSnapshot = surface.snapshot; - } - if (requestScopeRef.current !== scope) return; - const latest = applicationSnapshotRef.current; - if (!latest - || latest.preferenceRevision !== preferenceRevision - || latest.reviewSummary?.reviewId !== summary.reviewId - || Array.from(pendingMutations.current.values()).includes(scope)) return; - if (reboundSnapshot - && !acceptApplicationSnapshot(reboundSnapshot, scope, acceptedSequence.current)) return; - setApplicationReviewState((previous) => { - if (!previous || (append && previous.reviewId !== page.reviewId)) return previous; - const items = new Map( - (append ? previous.items : []).map((item) => [applicationReviewItemKey(item), item]), - ); - page.items.forEach((item) => items.set(applicationReviewItemKey(item), item)); - return { - ...previous, - reviewId: page.reviewId, - preferenceRevision: page.preferenceRevision, - loading: false, - items: Array.from(items.values()), - expectedGenerations: page.expectedGenerations, - nextCursor: page.nextCursor, - totalCount: page.totalCount, - recommendedCount: append - ? previous.recommendedCount - : authoritativeSummary.recommendationSummary.recommendedCount, - maxSelectionCount: append - ? previous.maxSelectionCount - : authoritativeSummary.maxSelectionCount, - }; - }); - } catch (reviewError) { - if (requestScopeRef.current !== scope) return; - setApplicationReviewState((previous) => ( - append && previous ? { ...previous, loading: false } : null - )); - setError({ kind: 'load', ...externalOperationErrorFacts(reviewError) }); - } - }, [acceptApplicationSnapshot, applicationReviewState?.expectedGenerations, - applicationReviewState?.preferenceRevision, applicationReviewState?.reviewId, - applicationSnapshot, requestScope, workspacePath]); - - useEffect(() => { - setApplicationReviewState((previous) => { - if (!previous || previous.submitted) return previous; - return applicationSnapshot?.reviewSummary?.reviewId === previous.reviewId - && applicationSnapshot.preferenceRevision === previous.preferenceRevision - ? previous - : null; - }); - }, [applicationSnapshot?.preferenceRevision, applicationSnapshot?.reviewSummary?.reviewId]); - - const selectedApplicationReviewCount = useMemo(() => { - if (!applicationReviewState) return 0; - let selectedCount = applicationReviewState.recommendedCount; - Object.entries(applicationReviewState.overrides).forEach(([key, selected]) => { - const item = applicationReviewState.items.find( - (candidate) => applicationReviewItemKey(candidate) === key, - ); - if (item && selected !== item.recommended) selectedCount += selected ? 1 : -1; - }); - return selectedCount; - }, [applicationReviewState]); - - const setApplicationReviewItemSelected = useCallback(( - item: ExternalApplicationReviewItemV2, - selected: boolean, - ) => { - const maximum = applicationReviewState?.maxSelectionCount ?? 0; - if (selected && selectedApplicationReviewCount >= maximum) { - setOperationStatus(t('applications.review.selectionLimit')); - return; - } - const key = applicationReviewItemKey(item); - setApplicationReviewState((previous) => { - if (!previous) return previous; - const overrides = { ...previous.overrides }; - if (selected === item.recommended) delete overrides[key]; - else overrides[key] = selected; - return { ...previous, overrides }; - }); - }, [applicationReviewState?.maxSelectionCount, selectedApplicationReviewCount, t]); - - const runApplicationAction = useCallback(async ( - action: ExternalApplicationControlActionV2, - mutationKey: string, - ): Promise => { - const current = applicationSnapshot; - if (!current || !canMutateApplicationScope) return null; - const scope = requestScope; - const sequence = ++requestSequence.current; - pendingMutations.current.set(sequence, scope); - latestMutationByScope.current.set(scope, sequence); - activeMutation.current = { scope, sequence }; - setBusyKey(mutationKey); - setOperationStatus(null); - setError(null); - let result: ExternalApplicationControlResultV2 | null = null; - try { - result = await externalSourcesAPI.applyApplicationAction(workspacePath, { - schemaVersion: 2, - executionDomainId: current.executionDomainId, - ...(current.workspaceScopeId ? { workspaceScopeId: current.workspaceScopeId } : {}), - targetScope: current.workspaceScopeId ? 'workspace_override' : 'user_default', - operationId: nextApplicationOperationId(), - expectedPreferenceRevision: current.preferenceRevision, - action, - }); - if (requestScopeRef.current === scope - && (latestMutationByScope.current.get(scope) ?? sequence) <= sequence) { - acceptedSequence.current = Math.max(acceptedSequence.current, sequence); - const partial = result.itemResults.some((item) => item.outcome !== 'applied'); - setOperationStatus(t(`applications.review.outcome.${partial ? 'partial' : result.outcome}`)); - } - } catch (mutationError) { - if (requestScopeRef.current === scope) { - setError({ kind: 'mutation', ...externalOperationErrorFacts(mutationError) }); - } - } finally { - pendingMutations.current.delete(sequence); - if (activeMutation.current?.scope === scope - && activeMutation.current.sequence === sequence) { - activeMutation.current = null; - setBusyKey(null); - } - } - if (result && requestScopeRef.current === scope) await loadSnapshot(true, false); - return result; - }, [applicationSnapshot, canMutateApplicationScope, loadSnapshot, requestScope, t, workspacePath]); - - const submitApplicationReview = useCallback(async ( - selectionBaseline: 'recommended' | 'none', - immediateSelection?: { item: ExternalApplicationReviewItemV2; selected: boolean }, - ) => { - const current = applicationReviewState; - if (!current) return; - const itemByKey = new Map( - current.items.map((item) => [applicationReviewItemKey(item), item]), - ); - const effectiveOverrides = new Map(Object.entries(current.overrides)); - if (immediateSelection) { - effectiveOverrides.set( - applicationReviewItemKey(immediateSelection.item), - immediateSelection.selected, - ); - } - const selectionOverrides = selectionBaseline === 'recommended' - ? Array.from(effectiveOverrides.entries()).flatMap(([key, selected]) => { - const item = itemByKey.get(key); - return item ? [{ itemRef: item.itemRef, selected }] : []; - }) - : []; - setApplicationReviewState((previous) => previous - ? { ...previous, submitted: true } - : previous); - const result = await runApplicationAction({ - type: 'submit_application_review', - reviewId: current.reviewId, - expectedGenerations: current.expectedGenerations, - selectionBaseline, - selectionOverrides, - }, 'application-review'); - if (result) { - setApplicationReviewState((previous) => result.outcome === 'stale' - ? null - : previous - ? { - ...previous, - itemResults: result.itemResults, - nextCursor: undefined, - submitted: true, - } - : previous); - } else { - setApplicationReviewState((previous) => previous - ? { ...previous, submitted: false } - : previous); - } - }, [applicationReviewState, runApplicationAction]); const commandConflicts = useMemo( () => unresolvedFirst(snapshot?.commandConflicts ?? []), @@ -1011,12 +697,9 @@ const ExternalSourcesConfig: React.FC = ({ canRevealSourceLocation: false, }; const control = snapshot?.control; - const canRefresh = applicationSnapshot?.hostCapabilities.canRefresh - ?? hostCapabilities.canRefresh; - const safeModeEnabled = applicationSnapshot?.safeMode ?? control?.safeMode; - const canSetSafeMode = applicationSnapshot - ? canMutateApplicationScope && applicationSnapshot.hostCapabilities.canSetSafeMode - : hostCapabilities.canSetSafeMode; + const canRefresh = hostCapabilities.canRefresh; + const safeModeEnabled = control?.safeMode; + const canSetSafeMode = hostCapabilities.canSetSafeMode; const policyStatus = snapshot?.integrationPolicy?.status; const policyCompatible = policyStatus === 'compatible'; const policyIncompatible = policyStatus === 'incompatible_schema'; @@ -1025,9 +708,6 @@ const ExternalSourcesConfig: React.FC = ({ && !hostCapabilities.canManageSources && !hostCapabilities.canApproveRuntime && !hostCapabilities.canSetSafeMode; - const applicationHostReadOnly = Boolean(applicationSnapshot) - && !canMutateApplicationScope - && !applicationSnapshot?.hostCapabilities.canSetSafeMode; const remoteWorkspace = workspace?.workspaceKind === WorkspaceKind.Remote; const readOnlyHintKey = remoteWorkspace ? 'policy.remoteReadOnlyHint' @@ -1133,11 +813,6 @@ const ExternalSourcesConfig: React.FC = ({ }, [runMutation, workspacePath]); const setSafeMode = useCallback(async (enabled: boolean) => { - if (applicationSnapshot) { - if (!canSetSafeMode) return; - await runApplicationAction({ type: 'set_safe_mode', enabled }, 'external-safe-mode'); - return; - } const currentSnapshot = snapshotRef.current; if (!currentSnapshot?.control) return; await runMutation( @@ -1153,7 +828,7 @@ const ExternalSourcesConfig: React.FC = ({ 'canSetSafeMode', 'none', ); - }, [applicationSnapshot, canSetSafeMode, runApplicationAction, runMutation, t, workspacePath]); + }, [runMutation, t, workspacePath]); const chooseConflict = useCallback(async (conflictKey: string, candidateId: string) => { if (!snapshot) return; @@ -1452,13 +1127,6 @@ const ExternalSourcesConfig: React.FC = ({ application: ExternalApplicationView, enabled: boolean, ) => { - if (applicationSnapshot && application.applicationId) { - await runApplicationAction({ - type: enabled ? 'connect_application' : 'disconnect_application', - applicationId: application.applicationId, - }, `application:${application.applicationId}`); - return; - } if (!snapshot) return; const storedPolicy = ecosystemPolicies.find( (ecosystem) => ecosystem.ecosystemId === application.ecosystemId, @@ -1475,9 +1143,7 @@ const ExternalSourcesConfig: React.FC = ({ mode, }); }, [ - applicationSnapshot, ecosystemPolicies, - runApplicationAction, snapshot, updatePolicy, ]); @@ -1514,10 +1180,16 @@ const ExternalSourcesConfig: React.FC = ({ ); }, [requestScope, runMutation, t]); - const scrollToFirstAttentionItem = useCallback(() => { - const target = document.querySelector( - '[data-external-attention="true"]', - ); + const scrollToFirstAttentionItem = useCallback((ecosystemId?: string) => { + const matchingEcosystemElements = ecosystemId + ? Array.from(document.querySelectorAll('[data-external-ecosystem]')) + .filter((element) => element.dataset.externalEcosystem === ecosystemId) + : []; + const target = ecosystemId + ? matchingEcosystemElements.find( + (element) => element.dataset.externalAttention === 'true', + ) ?? matchingEcosystemElements[0] + : document.querySelector('[data-external-attention="true"]'); if (!target) return; target.scrollIntoView({ block: 'center', behavior: 'smooth' }); if (target instanceof HTMLDetailsElement) { @@ -1534,11 +1206,22 @@ const ExternalSourcesConfig: React.FC = ({ target.focus(); }, []); - const openAdvanced = useCallback(() => { + const openAdvancedAttention = useCallback((ecosystemId: string) => { setAdvancedOpen(true); - window.requestAnimationFrame(scrollToFirstAttentionItem); + setExpandedEcosystems((current) => new Set(current).add(ecosystemId)); + window.requestAnimationFrame(() => scrollToFirstAttentionItem(ecosystemId)); }, [scrollToFirstAttentionItem]); + const openAdvancedPolicy = useCallback(() => { + setAdvancedOpen(true); + window.requestAnimationFrame(() => { + const policyCard = document.querySelector('[data-bf-part="policyCard"]'); + if (!policyCard) return; + policyCard.scrollIntoView({ block: 'center', behavior: 'smooth' }); + policyCard.querySelector('input[type="checkbox"]')?.focus(); + }); + }, []); + const revealSourceLocation = useCallback(async (sourceKey: string): Promise => { const scope = requestScope; if (snapshotRef.current?.hostCapabilities.canRevealSourceLocation !== true) { @@ -1852,7 +1535,7 @@ const ExternalSourcesConfig: React.FC = ({ key={action.type} size="small" variant="secondary" - onClick={scrollToFirstAttentionItem} + onClick={() => scrollToFirstAttentionItem()} > {t(`recoveryActions.${action.type}`)} @@ -1879,7 +1562,7 @@ const ExternalSourcesConfig: React.FC = ({ ) : null} ) : null} - {(snapshot && hostReadOnly) || applicationHostReadOnly ? ( + {snapshot && hostReadOnly ? (
) : null} {safeModeEnabled ? safeModeSection : null} - {snapshot || applicationSnapshot ? ( + {snapshot ? ( void toggleApplication(application, enabled)} - onOpenAdvanced={openAdvanced} - onOpenReview={applicationSnapshot?.reviewSummary - ? () => void loadApplicationReviewPage() - : undefined} - review={applicationSnapshot && applicationReviewState ? { - open: true, - loading: applicationReviewState.loading, - items: applicationReviewState.items, - selected: applicationReviewState.overrides, - selectedCount: selectedApplicationReviewCount, - recommendedCount: applicationReviewState.recommendedCount, - totalCount: applicationReviewState.totalCount, - maxSelectionCount: applicationReviewState.maxSelectionCount, - applicationNames: applicationSnapshot.applications - .filter((application) => application.pendingReviewCount > 0) - .map((application) => application.displayName), - nextCursor: applicationReviewState.nextCursor, - itemResults: applicationReviewState.itemResults, - completed: applicationReviewState.submitted, - canSubmit: canMutateApplicationScope, - onClose: () => setApplicationReviewState(null), - onToggleItem: setApplicationReviewItemSelected, - onLoadMore: () => { - if (applicationReviewState.nextCursor) { - void loadApplicationReviewPage(applicationReviewState.nextCursor); - } - }, - onSubmit: (baseline, immediateSelection) => void submitApplicationReview( - baseline, - immediateSelection, - ), - } : undefined} + onOpenAttention={openAdvancedAttention} + onOpenPolicy={openAdvancedPolicy} /> ) : null} {hookManagement} - {snapshot || applicationSnapshot ? ( + {snapshot ? (
= ({ - ) : null} - - {review?.open ? ( -
-
- -
- {openingReview ? ( - {t('applications.review.loading')}} - multiline - > - {null} - - ) : null} - {!openingReview ? ( - <> - + {application.displayName} + + {application.attentionCount > 0 ? ( + + - {singleReviewItem.safetyCeiling !== 'blocked' ? ( - - ) : null} - - ) : ( - <> - {review.selectedCount > 0 ? ( - - ) : null} - - - )} -
- - {canCustomizeReview ? ( -
- {t('applications.review.customize')} -
- {t('applications.review.selectionCount', { - selected: review.selectedCount, - maximum: review.maxSelectionCount, - })} -
-
- {review.items.map((item) => { - const key = reviewItemKey(item); - const selected = review.selected[key] ?? item.recommended; - const result = review.itemResults.find( - (candidate) => reviewItemRefKey(candidate.itemRef) === key, - ); - return ( - - ); - })} -
- {review.nextCursor ? ( -
- -
- ) : null} -
- ) : null} - +