From 70eb44984c407519a3386067d1560b5c8b39bc24 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 7 Aug 2026 17:22:16 +0800 Subject: [PATCH 1/2] 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 c177c473f1..15e9ccc1a3 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 0000000000..8185fa3cff --- /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 35e2db0e71..a2296504cc 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 4a918697db..3c22ccc445 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 0000000000..63fecd397e --- /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 6531352eeb..76dc0a66e0 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 0c2c8bbf7e..a07455141e 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 0000000000..734b9f7ea4 --- /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 c38cd9de27..cd5564c45f 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 08cec8c99d..6e02ff01b0 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 38375e4387..c1a6917fd0 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 b2b3e8531c..66628abaa6 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 0000000000..c485c0add8 --- /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 0000000000..10df9e3317 --- /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 e61d366e2e..aa21d5e3f4 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 8f2d8f4ba5..2c0aceb715 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 0000000000..e1e87a723d --- /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 0000000000..e1dd2ccf16 --- /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 8b917cecdd..b01a2402db 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 0000000000..b8c7593144 --- /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 0000000000..6a82568798 --- /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 f940c851e5..1c66b5a892 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 6dbb9e2694..db3f0fc7f1 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 1c6bae8410..045054b74a 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 d965d5b08e..f3e7176df9 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 7ad51ec5d1..a00d0ca784 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 72fb3c48cc..a4f939b8ca 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 06f477b898..139edbac82 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 a088f54d88..0382f2db61 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 486aad6a67..5b7e8d76a8 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 3c92d19156..07996fbb89 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 67b5b38130..539ea93aaa 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 9ed4245319..3396da0e91 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 d1d6b2d8c5..bfca15020c 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 a90edecec2..0a3cb71b96 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 0000000000..af5a2504d6 --- /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 227cccd2a2..283e2698a0 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 6adc633b73..854c234a46 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 89bacf2425..42a9a75af5 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 0000000000..cc029ab416 --- /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 0000000000..9af587e2af --- /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 bd1316fb2c..bb8c2ed7e9 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 0000000000..8db93d9fac --- /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 0000000000..11f1f41e09 --- /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 0000000000..4b3de88883 --- /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 417e97869e..58ca1bf03e 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 0ca41eeb88..32a712b546 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 0000000000..7d34cede75 --- /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 0000000000..c2d87b233f --- /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 b4b50b623b..0000000000 --- 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 b9009f449f..e34e22c5d1 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 0000000000..f86b14dd1c --- /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 a7db3241bc..a555f25aa0 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 88351095ff..7d51e1a0a7 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 5c4db437fe..a6bcb8bd0f 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 27e2d77f75..cb77d09d48 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 8af7b5da7f..3284ff69fb 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 53bf57347e..2b723f14d5 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 0000000000..b43aa96612 --- /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 0000000000..fc8300b1f6 --- /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 cd489b440c..0248180436 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 fd029d22ec..8228df45dd 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 a15d5ca378..40e66955c8 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 a3299361f5..c2de266168 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 fbedef6d7d..42ab00f630 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 87b9818dd8..f6d3d475ac 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 0000000000..02e90e9f0b --- /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 a83708868e..1d438f4309 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 a891801a75..528e3bf957 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 124f69ff32..22d8c6438e 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 39e3e9d2c5..9252b40cea 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 1af9cec0d6..8db8af9f1a 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 060199acea..0d4d2e8560 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 1139637387..4161ebe9fd 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 1c6a270efd..57de0bb8df 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 3fbd4c6b56..97a364d5b6 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 eaf64f2b06..471cc8ef7b 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 e6b32f7f91..d083ef6550 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 2/2] 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 71bceae0b0..0e6d177815 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 0000000000..2ccdeff49b --- /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.'); +}